import { useCallback, useEffect, useRef, useState } from "react";

import {
  decodeObservationSessionPreparation,
  deleteObservationSession,
  fetchObservationSessionCatalog,
  fetchObservationSessionPreparation,
  replayObservationSession,
  type ObservationSessionFetch,
  type ObservationSessionPreparation,
  type ObservationSessionReplayLaunch,
  type ObservationSessionSummary,
} from "./sessionArchive";

export type ObservationSessionsLoadState = "idle" | "loading" | "ready" | "error";
export type ObservationReplayOutcome = "accepted" | "error" | "cancelled";
export type ObservationPreparationPhase =
  | "requesting"
  | ObservationSessionPreparation["state"];

export interface ObservationReplayProgress {
  readonly sessionId: string;
  readonly phase: ObservationPreparationPhase;
  readonly progress: number | null;
  readonly cancellable: boolean;
}

export interface ObservationSessionsController {
  items: readonly ObservationSessionSummary[];
  state: ObservationSessionsLoadState;
  error: string | null;
  replayingSessionId: string | null;
  preparation: ObservationSessionPreparation | null;
  replayProgress: ObservationReplayProgress | null;
  failedSessionId: string | null;
  deletingSessionId: string | null;
  refresh: () => Promise<boolean>;
  replay: (sessionId: string) => Promise<boolean>;
  retry: () => Promise<boolean>;
  remove: (sessionId: string) => Promise<boolean>;
}

export interface ObservationReplayAttempt {
  readonly signal: AbortSignal;
  isCurrent: () => boolean;
  finish: () => boolean;
}

export interface ObservationReplayCoordinator {
  begin: () => ObservationReplayAttempt;
  cancel: () => void;
}

export interface ObservationPreparationPollingOptions {
  signal: AbortSignal;
  fetcher?: ObservationSessionFetch;
  onUpdate?: (preparation: ObservationSessionPreparation) => void;
  requestTimeoutMs?: number;
  heartbeatStallMs?: number;
  maximumWaitMs?: number;
  initialPollIntervalMs?: number;
  maximumPollIntervalMs?: number;
  now?: () => number;
  sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
}

const PREPARATION_STORAGE_KEY = "missioncore.observation-session-preparation/v1";
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
const DEFAULT_HEARTBEAT_STALL_MS = 45_000;
const DEFAULT_MAXIMUM_WAIT_MS = 30 * 60_000;
const DEFAULT_INITIAL_POLL_INTERVAL_MS = 750;
const DEFAULT_MAXIMUM_POLL_INTERVAL_MS = 3_000;

export class ObservationPreparationStalledError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "ObservationPreparationStalledError";
  }
}

/** Latest selection wins, even if an obsolete server job finishes later. */
export function createObservationReplayCoordinator(): ObservationReplayCoordinator {
  let sequence = 0;
  let active: AbortController | null = null;

  return {
    begin() {
      active?.abort();
      const controller = new AbortController();
      const attemptSequence = ++sequence;
      active = controller;
      return {
        signal: controller.signal,
        isCurrent: () => (
          !controller.signal.aborted &&
          active === controller &&
          sequence === attemptSequence
        ),
        finish: () => {
          if (active !== controller || sequence !== attemptSequence) return false;
          active = null;
          return true;
        },
      };
    },
    cancel() {
      active?.abort();
      // Keep ownership until the cancelled attempt reaches `finish()`. This
      // lets its finally block settle a replacement that already passed
      // onReplayBegin, while `isCurrent()` still fails immediately because the
      // signal is aborted. A later `begin()` replaces and invalidates it.
    },
  };
}

function errorMessage(error: unknown): string {
  return error instanceof Error && error.message.trim()
    ? error.message
    : "Операция с сохранёнными сессиями завершилась ошибкой.";
}

function isAbortError(error: unknown): boolean {
  return error instanceof DOMException && error.name === "AbortError";
}

type PendingPreparationState = Exclude<
  ObservationSessionPreparation["state"],
  "failed" | "cancelled"
>;

function pendingPreparation(
  preparation: ObservationSessionPreparation,
): preparation is ObservationSessionPreparation & { state: PendingPreparationState } {
  return preparation.state !== "failed" && preparation.state !== "cancelled";
}

const PREPARATION_PHASE_ORDER: Record<
  PendingPreparationState,
  number
> = {
  queued: 0,
  validating: 1,
  exporting: 2,
  finalizing: 3,
};

function terminalPreparationError(preparation: ObservationSessionPreparation): Error {
  if (preparation.error) return new Error(preparation.error);
  return new Error(
    preparation.state === "cancelled"
      ? "Подготовка записи отменена."
      : "Сервер не смог подготовить сохранённую сессию.",
  );
}

function defaultSleep(milliseconds: number, signal: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    if (signal.aborted) {
      reject(new DOMException("cancelled", "AbortError"));
      return;
    }
    const onAbort = () => {
      globalThis.clearTimeout(timer);
      reject(new DOMException("cancelled", "AbortError"));
    };
    const timer = globalThis.setTimeout(() => {
      signal.removeEventListener("abort", onAbort);
      resolve();
    }, milliseconds);
    signal.addEventListener("abort", onAbort, { once: true });
  });
}

async function withRequestTimeout<T>(
  signal: AbortSignal,
  timeoutMs: number,
  operation: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
  if (signal.aborted) throw new DOMException("cancelled", "AbortError");
  const controller = new AbortController();
  let timedOut = false;
  const forwardAbort = () => controller.abort();
  signal.addEventListener("abort", forwardAbort, { once: true });
  const timer = globalThis.setTimeout(() => {
    timedOut = true;
    controller.abort();
  }, timeoutMs);
  try {
    return await operation(controller.signal);
  } catch (error) {
    if (timedOut && isAbortError(error)) {
      throw new ObservationPreparationStalledError(
        "Сервер слишком долго не отвечает. Подготовку можно повторить.",
      );
    }
    throw error;
  } finally {
    globalThis.clearTimeout(timer);
    signal.removeEventListener("abort", forwardAbort);
  }
}

export async function waitForObservationReplayPreparation(
  initial: ObservationSessionPreparation,
  options: ObservationPreparationPollingOptions,
): Promise<ObservationSessionReplayLaunch> {
  if (!pendingPreparation(initial)) throw terminalPreparationError(initial);
  const requestTimeoutMs = Math.max(100, options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
  const heartbeatStallMs = Math.max(250, options.heartbeatStallMs ?? DEFAULT_HEARTBEAT_STALL_MS);
  const maximumWaitMs = Math.max(heartbeatStallMs, options.maximumWaitMs ?? DEFAULT_MAXIMUM_WAIT_MS);
  const initialInterval = Math.max(
    50,
    options.initialPollIntervalMs ?? DEFAULT_INITIAL_POLL_INTERVAL_MS,
  );
  const maximumInterval = Math.max(
    initialInterval,
    options.maximumPollIntervalMs ?? DEFAULT_MAXIMUM_POLL_INTERVAL_MS,
  );
  const now = options.now ?? Date.now;
  const sleep = options.sleep ?? defaultSleep;
  const startedAt = now();
  let lastHeartbeatAt = startedAt;
  let lastUpdatedAt = Date.parse(initial.updatedAtUtc);
  let current: ObservationSessionPreparation = initial;
  let interval = initialInterval;
  options.onUpdate?.(current);

  while (true) {
    await sleep(interval, options.signal);
    const response = await withRequestTimeout(
      options.signal,
      requestTimeoutMs,
      (signal) => fetchObservationSessionPreparation(current, {
        signal,
        fetcher: options.fetcher,
      }),
    );
    if (response.kind === "ready") return response.launch;

    const next = response.preparation;
    const nextUpdatedAt = Date.parse(next.updatedAtUtc);
    if (nextUpdatedAt < lastUpdatedAt) {
      throw new Error("Сервер вернул устаревшее состояние подготовки записи.");
    }
    if (nextUpdatedAt > lastUpdatedAt) {
      lastUpdatedAt = nextUpdatedAt;
      lastHeartbeatAt = now();
    } else if (
      next.state !== current.state ||
      next.progress !== current.progress ||
      next.cancellable !== current.cancellable
    ) {
      throw new Error("Сервер изменил подготовку без обновления heartbeat timestamp.");
    }
    if (pendingPreparation(next) && pendingPreparation(current)) {
      if (PREPARATION_PHASE_ORDER[next.state] < PREPARATION_PHASE_ORDER[current.state]) {
        throw new Error("Сервер вернул подготовку на уже завершённую фазу.");
      }
      if (
        next.progress !== null &&
        current.progress !== null &&
        next.progress + Number.EPSILON < current.progress
      ) {
        throw new Error("Прогресс подготовки не может уменьшаться.");
      }
    }
    current = next;
    options.onUpdate?.(current);
    if (!pendingPreparation(current)) throw terminalPreparationError(current);
    if (current.state !== "queued" && now() - lastHeartbeatAt > heartbeatStallMs) {
      throw new ObservationPreparationStalledError(
        "Подготовка перестала обновляться. Текущая сцена сохранена; повторите запуск.",
      );
    }
    if (now() - startedAt > maximumWaitMs) {
      throw new ObservationPreparationStalledError(
        "Подготовка превысила допустимое время. Текущая сцена сохранена; повторите запуск.",
      );
    }
    interval = Math.min(maximumInterval, Math.round(interval * 1.35));
  }
}

export async function resolveObservationSessionReplay(
  sessionId: string,
  options: ObservationPreparationPollingOptions,
): Promise<ObservationSessionReplayLaunch> {
  // The initial replay request may restore and integrity-check large, already
  // published artifacts before it can return either a launch descriptor or a
  // background-preparation handle. Its duration therefore scales with the
  // recording package and must not be confused with a stalled status poll.
  // Keep it cancellable by the owning UI attempt, but do not impose the short
  // per-poll timeout used once the server has returned a preparation handle.
  const response = await replayObservationSession(sessionId, {
    signal: options.signal,
    fetcher: options.fetcher,
  });
  if (response.kind === "ready") return response.launch;
  if (!pendingPreparation(response.preparation)) {
    options.onUpdate?.(response.preparation);
    throw terminalPreparationError(response.preparation);
  }
  return waitForObservationReplayPreparation(response.preparation, options);
}

function preparationStoragePayload(preparation: ObservationSessionPreparation): unknown {
  return {
    schema_version: "missioncore.observation-session-preparation/v1",
    preparation: {
      preparation_id: preparation.preparationId,
      session_id: preparation.sessionId,
      state: preparation.state,
      progress: preparation.progress,
      updated_at_utc: preparation.updatedAtUtc,
      status_url: preparation.statusUrl,
      cancellable: preparation.cancellable,
      ...(preparation.state === "failed" || preparation.state === "cancelled"
        ? { retryable: preparation.retryable, error: preparation.error }
        : {}),
    },
  };
}

export function storeObservationReplayPreparation(
  preparation: ObservationSessionPreparation,
  storage: Storage = window.localStorage,
): void {
  if (!pendingPreparation(preparation)) {
    storage.removeItem(PREPARATION_STORAGE_KEY);
    return;
  }
  storage.setItem(PREPARATION_STORAGE_KEY, JSON.stringify(preparationStoragePayload(preparation)));
}

export function loadObservationReplayPreparation(
  storage: Storage = window.localStorage,
): ObservationSessionPreparation | null {
  const serialized = storage.getItem(PREPARATION_STORAGE_KEY);
  if (!serialized) return null;
  try {
    const parsed = JSON.parse(serialized) as unknown;
    if (
      typeof parsed !== "object" ||
      parsed === null ||
      !("preparation" in parsed) ||
      typeof parsed.preparation !== "object" ||
      parsed.preparation === null ||
      !("session_id" in parsed.preparation) ||
      typeof parsed.preparation.session_id !== "string"
    ) {
      throw new Error("invalid persisted preparation");
    }
    const preparation = decodeObservationSessionPreparation(
      parsed,
      parsed.preparation.session_id,
    );
    return pendingPreparation(preparation) ? preparation : null;
  } catch {
    storage.removeItem(PREPARATION_STORAGE_KEY);
    return null;
  }
}

export function clearObservationReplayPreparation(
  storage: Storage = window.localStorage,
): void {
  storage.removeItem(PREPARATION_STORAGE_KEY);
}

export function useObservationSessions({
  limit = 100,
  replayEnabled = true,
  onReplayBegin,
  onReplayAccepted,
  onReplaySettled,
}: {
  limit?: number;
  replayEnabled?: boolean;
  /** Called only after the archive is ready, immediately before replacing the old viewer. */
  onReplayBegin?: (
    session: ObservationSessionSummary,
    launch: ObservationSessionReplayLaunch,
  ) => void | Promise<void>;
  onReplayAccepted?: (
    session: ObservationSessionSummary,
    launch: ObservationSessionReplayLaunch,
  ) => void | Promise<void>;
  onReplaySettled?: (
    session: ObservationSessionSummary,
    outcome: ObservationReplayOutcome,
  ) => void | Promise<void>;
} = {}): ObservationSessionsController {
  const [items, setItems] = useState<ObservationSessionSummary[]>([]);
  const [state, setState] = useState<ObservationSessionsLoadState>("idle");
  const [error, setError] = useState<string | null>(null);
  const [replayingSessionId, setReplayingSessionId] = useState<string | null>(null);
  const [preparation, setPreparation] = useState<ObservationSessionPreparation | null>(null);
  const [replayProgress, setReplayProgress] = useState<ObservationReplayProgress | null>(null);
  const [failedSessionId, setFailedSessionId] = useState<string | null>(null);
  const [deletingSessionId, setDeletingSessionId] = useState<string | null>(null);
  const mounted = useRef(true);
  const catalogSequence = useRef(0);
  const reattachStarted = useRef(false);
  const replayEnabledRef = useRef(replayEnabled);
  replayEnabledRef.current = replayEnabled;
  const replayCoordinator = useRef<ObservationReplayCoordinator | null>(null);
  if (replayCoordinator.current === null) {
    replayCoordinator.current = createObservationReplayCoordinator();
  }

  useEffect(() => {
    if (replayEnabled) return;
    // Cancels only this browser's polling/selection attempt. The shared
    // backend preparation remains untouched and can be selected again later.
    reattachStarted.current = true;
    replayCoordinator.current?.cancel();
    setReplayingSessionId(null);
    setReplayProgress(null);
  }, [replayEnabled]);
  const safeLimit = Number.isFinite(limit)
    ? Math.min(100, Math.max(1, Math.floor(limit)))
    : 100;

  const loadCatalog = useCallback(async (foreground: boolean) => {
    const sequence = ++catalogSequence.current;
    if (foreground) setState("loading");
    try {
      const catalog = await fetchObservationSessionCatalog({ limit: safeLimit });
      if (!mounted.current || sequence !== catalogSequence.current) return false;
      setItems(catalog.items.slice(0, safeLimit));
      setState("ready");
      setError(null);
      return true;
    } catch (loadError) {
      if (!mounted.current || sequence !== catalogSequence.current) return false;
      if (foreground) setState("error");
      setError(errorMessage(loadError));
      return false;
    }
  }, [safeLimit]);

  const refresh = useCallback(() => loadCatalog(true), [loadCatalog]);

  useEffect(() => {
    mounted.current = true;
    void refresh();
    return () => {
      mounted.current = false;
      catalogSequence.current += 1;
      replayCoordinator.current?.cancel();
    };
  }, [refresh]);

  useEffect(() => {
    if (state !== "ready") return;
    let disposed = false;
    let timer = 0;
    const poll = async () => {
      if (disposed) return;
      await loadCatalog(false);
      if (!disposed) timer = window.setTimeout(() => void poll(), 2_000);
    };
    timer = window.setTimeout(() => void poll(), 2_000);
    return () => {
      disposed = true;
      window.clearTimeout(timer);
    };
  }, [loadCatalog, state]);

  const executeReplay = useCallback(async (
    session: ObservationSessionSummary,
    resumedPreparation?: ObservationSessionPreparation,
  ) => {
    if (!replayEnabledRef.current) return false;
    const attempt = replayCoordinator.current!.begin();
    setReplayingSessionId(session.id);
    setFailedSessionId(null);
    setError(null);
    setReplayProgress({
      sessionId: session.id,
      phase: resumedPreparation?.state ?? "requesting",
      progress: resumedPreparation?.progress ?? null,
      cancellable: resumedPreparation?.cancellable ?? false,
    });
    let outcome: ObservationReplayOutcome = "cancelled";
    try {
      const onUpdate = (next: ObservationSessionPreparation) => {
        if (!mounted.current || !attempt.isCurrent()) return;
        setPreparation(next);
        setReplayProgress({
          sessionId: next.sessionId,
          phase: next.state,
          progress: next.progress,
          cancellable: next.cancellable,
        });
        try {
          storeObservationReplayPreparation(next);
        } catch {
          // Private browsing/storage quota must not break replay preparation.
        }
      };
      const launch = resumedPreparation
        ? await waitForObservationReplayPreparation(resumedPreparation, {
            signal: attempt.signal,
            onUpdate,
          })
        : await resolveObservationSessionReplay(session.id, {
            signal: attempt.signal,
            onUpdate,
          });
      if (
        !mounted.current ||
        !attempt.isCurrent() ||
        !replayEnabledRef.current
      ) return false;

      // The current scene stays mounted throughout preparation. Only now that
      // the launch descriptor exists do we release the previous viewer.
      await onReplayBegin?.(session, launch);
      if (
        !mounted.current ||
        !attempt.isCurrent() ||
        !replayEnabledRef.current
      ) return false;
      await onReplayAccepted?.(session, launch);
      if (
        !mounted.current ||
        !attempt.isCurrent() ||
        !replayEnabledRef.current
      ) return false;
      outcome = "accepted";
      try {
        clearObservationReplayPreparation();
      } catch {
        // Storage is optional; an accepted replay must not turn into an error.
      }
      setPreparation(null);
      setReplayProgress(null);
      return true;
    } catch (replayError) {
      const aborted = isAbortError(replayError);
      outcome = aborted ? "cancelled" : "error";
      if (mounted.current && attempt.isCurrent() && !aborted) {
        setError(errorMessage(replayError));
        setFailedSessionId(session.id);
        setReplayProgress(null);
        try {
          clearObservationReplayPreparation();
        } catch {
          // Storage is optional for the current browser lifetime.
        }
      }
      return false;
    } finally {
      const current = attempt.finish();
      if (mounted.current && current) {
        setReplayingSessionId(null);
        await onReplaySettled?.(session, outcome);
      }
    }
  }, [onReplayAccepted, onReplayBegin, onReplaySettled]);

  const replay = useCallback(async (sessionId: string) => {
    if (!replayEnabledRef.current) return false;
    const session = items.find((candidate) => candidate.id === sessionId);
    if (!session || !session.replayable) return false;
    reattachStarted.current = true;
    return executeReplay(session);
  }, [executeReplay, items]);

  useEffect(() => {
    if (!replayEnabled || state !== "ready" || reattachStarted.current) return;
    reattachStarted.current = true;
    let stored: ObservationSessionPreparation | null = null;
    try {
      stored = loadObservationReplayPreparation();
    } catch {
      stored = null;
    }
    if (!stored) return;
    const session = items.find((candidate) => candidate.id === stored?.sessionId);
    if (!session?.replayable) {
      try {
        clearObservationReplayPreparation();
      } catch {
        // Storage may be unavailable in hardened browser profiles.
      }
      return;
    }
    void executeReplay(session, stored);
  }, [executeReplay, items, replayEnabled, state]);

  const retry = useCallback(async () => {
    if (!failedSessionId) return false;
    return replay(failedSessionId);
  }, [failedSessionId, replay]);

  const remove = useCallback(async (sessionId: string) => {
    if (deletingSessionId !== null || replayingSessionId === sessionId) return false;
    setDeletingSessionId(sessionId);
    setError(null);
    try {
      await deleteObservationSession(sessionId);
      if (!mounted.current) return false;
      setItems((current) => current.filter((item) => item.id !== sessionId));
      setFailedSessionId((current) => current === sessionId ? null : current);
      return true;
    } catch (deleteError) {
      if (mounted.current) setError(errorMessage(deleteError));
      return false;
    } finally {
      if (mounted.current) setDeletingSessionId(null);
    }
  }, [deletingSessionId, replayingSessionId]);

  return {
    items,
    state,
    error,
    replayingSessionId,
    preparation,
    replayProgress,
    failedSessionId,
    deletingSessionId,
    refresh,
    replay,
    retry,
    remove,
  };
}
