import {
  isEntityPoseSnapshot,
  isInterestCell,
  validateClientRealtimeMessage,
  validateServerRealtimeMessage,
} from "./protocol.ts";
import type {
  EntityPoseSnapshot,
  InterestCell,
  JoinGrant,
  JoinRequest,
  ResumeGrant,
  ResumeRequest,
  ServerRealtimeMessage,
} from "./types.ts";

const MIN_SEND_INTERVAL_MS = 1_000 / 15;
const MAX_SEND_INTERVAL_MS = 1_000 / 10;
const UINT32_MAX = 4_294_967_295;

type Fetch = typeof globalThis.fetch;
type TimerHandle = ReturnType<typeof globalThis.setTimeout>;

export interface RealtimeClientTimers {
  setTimeout(callback: () => void, delayMs: number): TimerHandle;
  clearTimeout(handle: TimerHandle): void;
}

export interface RealtimeClientEndpoints {
  join: string;
  pose: string;
  events: string;
  leave: string;
}

export type RealtimeClientStatus =
  | "idle"
  | "joined"
  | "streaming"
  | "reconnecting"
  | "left"
  | "disposed";

export interface RealtimeClientState {
  status: RealtimeClientStatus;
  sessionId: string | null;
  actorId: string;
  interests: readonly InterestCell[];
  serverEpoch: string | null;
  lastReceivedSequence: number | null;
  nextPoseSequence: number;
  posePending: boolean;
  subscribed: boolean;
  reconnectAttempt: number;
}

export interface RealtimeSubscriptionCallbacks {
  onMessage?(message: ServerRealtimeMessage): void;
  onStateChange?(state: RealtimeClientState): void;
  /** Receives protocol/parser errors without the untrusted payload or credentials. */
  onMalformedEvent?(error: Error): void;
  onError?(error: Error): void;
}

export interface RealtimeClientOptions {
  actorId: string;
  /**
   * Optional SSO bearer, kept only in this closure and never placed in a URL.
   * Omit it when the deployment uses an HttpOnly same-origin session cookie.
   */
  accessToken?: string;
  /**
   * Preferred request adapter for hosted builds. Passing `authFetch` keeps SSO
   * token lookup at request time and also supports HttpOnly cookie sessions.
   */
  authenticatedFetch?: Fetch;
  endpoints?: Partial<RealtimeClientEndpoints>;
  fetch?: Fetch;
  timers?: RealtimeClientTimers;
  sendIntervalMs?: number;
  reconnectBaseMs?: number;
  reconnectMaximumMs?: number;
  reconnectJitter?: number;
  random?: () => number;
}

export interface RealtimeClient {
  join(
    interest: InterestCell | readonly InterestCell[],
    entity: EntityPoseSnapshot,
  ): Promise<JoinGrant>;
  publishPose(entity: EntityPoseSnapshot): void;
  subscribe(callbacks: RealtimeSubscriptionCallbacks): () => void;
  moveInterest(interest: InterestCell | readonly InterestCell[]): Promise<ResumeGrant>;
  rejoin(): Promise<ResumeGrant>;
  state(): RealtimeClientState;
  leave(): Promise<void>;
  dispose(): Promise<void>;
}

const DEFAULT_ENDPOINTS: RealtimeClientEndpoints = {
  join: "/api/v1/realtime/join",
  pose: "/api/v1/realtime/pose",
  events: "/api/v1/realtime/events",
  leave: "/api/v1/realtime/leave",
};

const DEFAULT_TIMERS: RealtimeClientTimers = {
  setTimeout: (callback, delayMs) => globalThis.setTimeout(callback, delayMs),
  clearTimeout: (handle) => globalThis.clearTimeout(handle),
};

function requireNonEmpty(value: string, name: string): void {
  if (value.length === 0 || value.length > 256) throw new TypeError(`${name} must be 1-256 characters`);
}

function normalizeInterests(value: InterestCell | readonly InterestCell[]): readonly InterestCell[] {
  const values = Array.isArray(value) ? value : [value];
  if (values.length === 0 || values.length > 128 || !values.every(isInterestCell)) {
    throw new TypeError("realtime interests are invalid");
  }
  return values.map((interest) => ({ ...interest }));
}

function errorFrom(reason: unknown, fallback: string): Error {
  return reason instanceof Error ? reason : new Error(fallback);
}

function isTerminalStatus(status: RealtimeClientStatus): boolean {
  return status === "disposed" || status === "left";
}

/**
 * Browser transport for the server-authoritative realtime protocol.
 *
 * Streaming uses fetch + ReadableStream instead of EventSource. This permits a
 * normal Authorization header and POST body, keeping both the access token and
 * resume token out of URLs, referrers, history, and intermediary URL logs.
 */
export function createRealtimeClient(options: RealtimeClientOptions): RealtimeClient {
  requireNonEmpty(options.actorId, "actorId");
  if (options.accessToken !== undefined) requireNonEmpty(options.accessToken, "accessToken");
  if (options.fetch && options.authenticatedFetch) {
    throw new TypeError("provide either fetch or authenticatedFetch, not both");
  }
  const fetcher = options.authenticatedFetch ?? options.fetch ?? globalThis.fetch;
  if (typeof fetcher !== "function") throw new TypeError("fetch is required");
  const timers = options.timers ?? DEFAULT_TIMERS;
  const endpoints = { ...DEFAULT_ENDPOINTS, ...options.endpoints };
  for (const [name, endpoint] of Object.entries(endpoints)) requireNonEmpty(endpoint, `${name} endpoint`);

  const sendIntervalMs = options.sendIntervalMs ?? MAX_SEND_INTERVAL_MS;
  if (!Number.isFinite(sendIntervalMs) || sendIntervalMs < MIN_SEND_INTERVAL_MS || sendIntervalMs > MAX_SEND_INTERVAL_MS) {
    throw new RangeError("sendIntervalMs must be between 66.67ms (15Hz) and 100ms (10Hz)");
  }
  const reconnectBaseMs = options.reconnectBaseMs ?? 500;
  const reconnectMaximumMs = options.reconnectMaximumMs ?? 30_000;
  const reconnectJitter = options.reconnectJitter ?? 0.2;
  if (!Number.isFinite(reconnectBaseMs) || reconnectBaseMs <= 0) throw new RangeError("reconnectBaseMs must be positive");
  if (!Number.isFinite(reconnectMaximumMs) || reconnectMaximumMs < reconnectBaseMs) {
    throw new RangeError("reconnectMaximumMs must be at least reconnectBaseMs");
  }
  if (!Number.isFinite(reconnectJitter) || reconnectJitter < 0 || reconnectJitter > 1) {
    throw new RangeError("reconnectJitter must be between zero and one");
  }
  const random = options.random ?? Math.random;

  let status: RealtimeClientStatus = "idle";
  let interests: readonly InterestCell[] = [];
  let sessionId: string | null = null;
  let serverEpoch: string | null = null;
  let resumeToken: string | null = null;
  let lastReceivedSequence: number | null = null;
  let nextPoseSequence = 0;
  let pendingPose: EntityPoseSnapshot | null = null;
  let poseTimer: TimerHandle | null = null;
  let reconnectTimer: TimerHandle | null = null;
  let reconnectAttempt = 0;
  let subscription: RealtimeSubscriptionCallbacks | null = null;
  let streamAbort: AbortController | null = null;
  // Resume rotates the credential. Do not race a pose against the first grant
  // and send the token the server has just retired.
  let streamReady = false;
  let streamGeneration = 0;
  // Scene changes can overtake join/resume HTTP calls. Only the newest
  // transition may commit credentials or interest cells.
  let transitionGeneration = 0;
  let requestCounter = 0;

  function snapshot(): RealtimeClientState {
    return {
      status,
      sessionId,
      actorId: options.actorId,
      interests: interests.map((interest) => ({ ...interest })),
      serverEpoch,
      lastReceivedSequence,
      nextPoseSequence,
      posePending: pendingPose !== null,
      subscribed: subscription !== null,
      reconnectAttempt,
    };
  }

  function changed(): void {
    subscription?.onStateChange?.(snapshot());
  }

  function requestId(): string {
    requestCounter += 1;
    return `tera-client-${requestCounter}`;
  }

  function authHeaders(accept = "application/json"): HeadersInit {
    const headers: Record<string, string> = {
      Accept: accept,
      "Content-Type": "application/json",
    };
    if (options.accessToken !== undefined) headers.Authorization = `Bearer ${options.accessToken}`;
    return headers;
  }

  async function post(endpoint: string, body: unknown, accept?: string, signal?: AbortSignal): Promise<Response> {
    return fetcher(endpoint, {
      method: "POST",
      credentials: "same-origin",
      cache: "no-store",
      redirect: "error",
      headers: authHeaders(accept),
      body: JSON.stringify(body),
      signal,
    });
  }

  async function json(response: Response): Promise<unknown> {
    if (!response.ok) throw new Error(`realtime request failed (${response.status})`);
    return response.json() as Promise<unknown>;
  }

  function cancelPose(): void {
    if (poseTimer !== null) timers.clearTimeout(poseTimer);
    poseTimer = null;
    pendingPose = null;
  }

  function cancelStream(): void {
    streamGeneration += 1;
    streamAbort?.abort();
    streamAbort = null;
    streamReady = false;
    if (reconnectTimer !== null) timers.clearTimeout(reconnectTimer);
    reconnectTimer = null;
  }

  function acceptMessage(message: ServerRealtimeMessage): boolean {
    if (serverEpoch !== null && "serverEpoch" in message && message.serverEpoch !== serverEpoch) {
      subscription?.onMalformedEvent?.(new Error("realtime event belongs to a different server epoch"));
      return false;
    }
    if (message.type === "pose-delta" || message.type === "membership-revoked") {
      if (lastReceivedSequence !== null && message.sequence <= lastReceivedSequence) {
        subscription?.onMalformedEvent?.(new Error("realtime event sequence is not monotonic"));
        return false;
      }
      lastReceivedSequence = message.sequence;
    } else if (message.type === "resume-grant") {
      resumeToken = message.resumeToken;
      serverEpoch = message.serverEpoch;
      lastReceivedSequence = message.nextSequence === 0 ? 0 : message.nextSequence - 1;
      streamReady = true;
      status = "streaming";
    }
    reconnectAttempt = 0;
    subscription?.onMessage?.(message);
    if (message.type === "membership-revoked" && !message.reconnectAllowed) {
      cancelStream();
      status = "left";
      resumeToken = null;
    }
    changed();
    return true;
  }

  function parseEvent(data: string): void {
    if (data.length === 0) return;
    let raw: unknown;
    try {
      raw = JSON.parse(data);
    } catch {
      subscription?.onMalformedEvent?.(new Error("realtime event is not valid JSON"));
      return;
    }
    const result = validateServerRealtimeMessage(raw);
    if (!result.ok) {
      subscription?.onMalformedEvent?.(new Error(result.error));
      return;
    }
    acceptMessage(result.value);
  }

  async function readSse(response: Response, generation: number): Promise<void> {
    if (!response.ok) throw new Error(`realtime stream failed (${response.status})`);
    if (!response.body) throw new Error("realtime stream response has no body");
    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = "";
    let dataLines: string[] = [];
    const line = (value: string): void => {
      if (value === "") {
        if (dataLines.length > 0) parseEvent(dataLines.join("\n"));
        dataLines = [];
      } else if (value.startsWith("data:")) {
        dataLines.push(value.slice(5).replace(/^ /, ""));
      }
    };
    try {
      while (generation === streamGeneration) {
        const chunk = await reader.read();
        // An abort and an already-queued chunk may settle together. Never
        // parse the old cell after another transition has won that race.
        if (generation !== streamGeneration) break;
        if (chunk.done) break;
        buffer += decoder.decode(chunk.value, { stream: true }).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
        let newline = buffer.indexOf("\n");
        while (newline >= 0) {
          line(buffer.slice(0, newline));
          buffer = buffer.slice(newline + 1);
          newline = buffer.indexOf("\n");
        }
      }
      if (generation !== streamGeneration) return;
      buffer += decoder.decode();
      if (buffer.length > 0) line(buffer);
      if (dataLines.length > 0) parseEvent(dataLines.join("\n"));
    } finally {
      reader.releaseLock();
    }
  }

  function reconnectDelay(): number {
    const exponential = Math.min(reconnectMaximumMs, reconnectBaseMs * 2 ** reconnectAttempt);
    const factor = 1 + (random() * 2 - 1) * reconnectJitter;
    return Math.max(0, exponential * factor);
  }

  function scheduleReconnect(): void {
    if (!subscription || status === "left" || status === "disposed" || reconnectTimer !== null) return;
    status = "reconnecting";
    const delay = reconnectDelay();
    reconnectAttempt += 1;
    changed();
    reconnectTimer = timers.setTimeout(() => {
      reconnectTimer = null;
      void startStream();
    }, delay);
  }

  async function startStream(): Promise<void> {
    if (!subscription || !sessionId || !resumeToken || !serverEpoch || lastReceivedSequence === null) return;
    cancelStream();
    const generation = streamGeneration;
    const abort = new AbortController();
    streamAbort = abort;
    const resume: ResumeRequest = {
      type: "resume-request",
      protocolVersion: 1,
      requestId: requestId(),
      sessionId,
      resumeToken,
      serverEpoch,
      lastReceivedSequence,
      interests,
    };
    if (!validateClientRealtimeMessage(resume).ok) throw new Error("realtime resume state is invalid");
    try {
      const response = await post(endpoints.events, resume, "text/event-stream", abort.signal);
      if (generation !== streamGeneration) return;
      await readSse(response, generation);
      if (generation === streamGeneration) scheduleReconnect();
    } catch (reason) {
      if (generation !== streamGeneration || abort.signal.aborted) return;
      subscription?.onError?.(errorFrom(reason, "realtime stream failed"));
      scheduleReconnect();
    }
  }

  async function sendPose(): Promise<void> {
    poseTimer = null;
    const pose = pendingPose;
    pendingPose = null;
    if (!pose || !sessionId || status === "left" || status === "disposed") return;
    if (subscription && !streamReady) {
      pendingPose = pose;
      poseTimer = timers.setTimeout(() => void sendPose(), sendIntervalMs);
      return;
    }
    try {
      const response = await post(endpoints.pose, { sessionId, token: resumeToken, snapshot: pose });
      if (!response.ok) throw new Error(`realtime pose request failed (${response.status})`);
    } catch (reason) {
      subscription?.onError?.(errorFrom(reason, "realtime pose request failed"));
    }
    changed();
  }

  async function join(interest: InterestCell | readonly InterestCell[], entity: EntityPoseSnapshot): Promise<JoinGrant> {
    if (status === "disposed") throw new Error("realtime client is disposed");
    const generation = ++transitionGeneration;
    const nextInterests = normalizeInterests(interest);
    if (!isEntityPoseSnapshot(entity)) throw new TypeError("realtime entity pose is invalid");
    const request: JoinRequest = {
      type: "join-request",
      protocolVersion: 1,
      requestId: requestId(),
      actorId: options.actorId,
      resumeToken,
      lastReceivedSequence,
      interests: nextInterests,
    };
    if (!validateClientRealtimeMessage(request).ok) throw new Error("realtime join request is invalid");
    const parsed = validateServerRealtimeMessage(await json(await post(endpoints.join, { ...request, entity })));
    if (!parsed.ok || parsed.value.type !== "join-grant") throw new Error("realtime join response is invalid");
    const grant = parsed.value;
    if (grant.actorId !== options.actorId || grant.requestId !== request.requestId) {
      throw new Error("realtime join response identity does not match request");
    }
    if (generation !== transitionGeneration || isTerminalStatus(status)) {
      // A superseded request can still have created a server session before
      // the browser ignored its answer. Retire it without adopting its token.
      void post(endpoints.leave, { sessionId: grant.sessionId, token: grant.resumeToken })
        .catch(() => { /* normal idle expiry is the fallback */ });
      throw new Error("realtime join was superseded");
    }
    interests = grant.interests.map((entry) => ({ ...entry }));
    sessionId = grant.sessionId;
    serverEpoch = grant.serverEpoch;
    resumeToken = grant.resumeToken;
    lastReceivedSequence = grant.nextSequence === 0 ? 0 : grant.nextSequence - 1;
    nextPoseSequence = Math.max(entity.sequence + 1, grant.nextSequence);
    status = "joined";
    streamReady = subscription === null;
    reconnectAttempt = 0;
    changed();
    if (subscription && lastReceivedSequence !== null) void startStream();
    return grant;
  }

  function publishPose(entity: EntityPoseSnapshot): void {
    if (!sessionId || status === "idle" || status === "left" || status === "disposed") {
      throw new Error("realtime client must be joined before publishing a pose");
    }
    if (!isEntityPoseSnapshot(entity)) throw new TypeError("realtime entity pose is invalid");
    if (nextPoseSequence > UINT32_MAX) throw new RangeError("realtime pose sequence exhausted");
    const sequenced = { ...entity, sequence: nextPoseSequence } as EntityPoseSnapshot;
    if (!isEntityPoseSnapshot(sequenced)) throw new TypeError("realtime entity pose is invalid");
    nextPoseSequence += 1;
    pendingPose = sequenced;
    if (poseTimer === null) poseTimer = timers.setTimeout(() => void sendPose(), sendIntervalMs);
    changed();
  }

  function subscribe(callbacks: RealtimeSubscriptionCallbacks): () => void {
    if (status === "disposed") throw new Error("realtime client is disposed");
    subscription = callbacks;
    changed();
    if (sessionId && lastReceivedSequence !== null) void startStream();
    let active = true;
    return () => {
      if (!active || subscription !== callbacks) return;
      active = false;
      subscription = null;
      cancelStream();
      if (status === "streaming" || status === "reconnecting") status = sessionId ? "joined" : "idle";
    };
  }

  async function resume(nextInterests: readonly InterestCell[]): Promise<ResumeGrant> {
    if (status === "disposed") throw new Error("realtime client is disposed");
    if (!sessionId || !resumeToken || !serverEpoch || lastReceivedSequence === null) {
      throw new Error("realtime client has no resumable session");
    }
    const generation = ++transitionGeneration;
    const expectedSessionId = sessionId;
    const request: ResumeRequest = {
      type: "resume-request",
      protocolVersion: 1,
      requestId: requestId(),
      sessionId,
      resumeToken,
      serverEpoch,
      lastReceivedSequence,
      interests: nextInterests,
    };
    if (!validateClientRealtimeMessage(request).ok) throw new Error("realtime resume request is invalid");
    // The old stream belongs to a different privacy and coordinate cell. Close
    // it before the handoff request, not after its network round trip.
    cancelStream();
    status = "joined";
    changed();
    try {
      const parsed = validateServerRealtimeMessage(await json(await post(endpoints.join, request)));
      if (!parsed.ok || parsed.value.type !== "resume-grant") throw new Error("realtime resume response is invalid");
      const grant = parsed.value;
      if (grant.sessionId !== expectedSessionId || grant.requestId !== request.requestId) {
        throw new Error("realtime resume response identity does not match request");
      }
      if (generation !== transitionGeneration || isTerminalStatus(status)) {
        throw new Error("realtime resume was superseded");
      }
      interests = nextInterests.map((entry) => ({ ...entry }));
      resumeToken = grant.resumeToken;
      serverEpoch = grant.serverEpoch;
      lastReceivedSequence = grant.nextSequence === 0 ? 0 : grant.nextSequence - 1;
      streamReady = subscription === null;
      reconnectAttempt = 0;
      changed();
      if (subscription && lastReceivedSequence !== null) void startStream();
      return grant;
    } catch (error) {
      if (generation === transitionGeneration && !isTerminalStatus(status)) {
        status = "joined";
        changed();
      }
      throw error;
    }
  }

  async function rejoin(): Promise<ResumeGrant> {
    return resume(interests);
  }

  async function moveInterest(interest: InterestCell | readonly InterestCell[]): Promise<ResumeGrant> {
    return resume(normalizeInterests(interest));
  }

  async function leave(): Promise<void> {
    if (status === "disposed") return;
    transitionGeneration += 1;
    cancelPose();
    cancelStream();
    const leavingSession = sessionId;
    const leavingToken = resumeToken;
    status = "left";
    sessionId = null;
    serverEpoch = null;
    resumeToken = null;
    lastReceivedSequence = null;
    reconnectAttempt = 0;
    changed();
    if (leavingSession && leavingToken) {
      const response = await post(endpoints.leave, { sessionId: leavingSession, token: leavingToken });
      if (!response.ok) throw new Error(`realtime leave request failed (${response.status})`);
    }
  }

  async function dispose(): Promise<void> {
    if (status === "disposed") return;
    try {
      await leave();
    } finally {
      subscription = null;
      status = "disposed";
    }
  }

  return { join, publishPose, subscribe, moveInterest, rejoin, state: snapshot, leave, dispose };
}
