// ---------------------------------------------------------------------------
// openOAuthPopup — browser popup opener for OAuth flows.
//
// Opens a centered popup window pointed at an authorization URL, listens
// for the result via `postMessage` and `BroadcastChannel` (Safari fallback),
// and settles exactly once. Has NO React-specific imports so it can be used
// from any browser context, but lives under the `/react` entry to signal
// it is browser-only and should not be imported from Node / worker code.
// ---------------------------------------------------------------------------

import {
  isOAuthPopupResult as sharedIsOAuthPopupResult,
  type OAuthPopupResult,
} from "@executor-js/sdk/shared";
import { getExecutorServerAuthorizationHeader } from "./server-connection";

export { OAUTH_POPUP_MESSAGE_TYPE } from "@executor-js/sdk/shared";
export type { OAuthPopupResult } from "@executor-js/sdk/shared";

export const isOAuthPopupResult = sharedIsOAuthPopupResult;

// ---------------------------------------------------------------------------
// openOAuthPopup
// ---------------------------------------------------------------------------

export type OpenOAuthPopupInput<TAuth> = {
  readonly url: string;
  readonly onResult: (data: OAuthPopupResult<TAuth>) => void;
  readonly reservedPopup?: ReservedOAuthPopup;
  /** Ignore popup messages for any other in-flight OAuth session. */
  readonly expectedSessionId?: string;
  /** `window.open` target name — also used to focus an existing popup. */
  readonly popupName: string;
  /** BroadcastChannel name, must match the server-side `popupDocument` channel. */
  readonly channelName: string;
  readonly onOpenFailed?: () => void;
  /**
   * Called if the user closes the popup window without completing the
   * flow (detected via a `popup.closed` poll). NOT called when the popup
   * closes itself after a successful result post — `onResult` handles
   * that path. Also not called if the caller invokes the teardown
   * function returned from this function.
   */
  readonly onClosed?: () => void;
  readonly width?: number;
  readonly height?: number;
  /** How often to poll `popup.closed`. Default 500ms. Set to null to disable. */
  readonly closedPollMs?: number | null;
};

export type ReservedOAuthPopup = {
  readonly popup: Window;
};

const isHttpPopupUrl = (value: string): boolean => {
  if (!URL.canParse(value)) return false;
  const url = new URL(value);
  return url.protocol === "http:" || url.protocol === "https:";
};

const oauthPopupFeatures = (input: { readonly width?: number; readonly height?: number }) => {
  const w = input.width ?? 640;
  const h = input.height ?? 760;
  const left = window.screenX + (window.outerWidth - w) / 2;
  const top = window.screenY + (window.outerHeight - h) / 2;
  return `width=${w},height=${h},left=${left},top=${top},popup=1`;
};

export const reserveOAuthPopup = (input: {
  readonly popupName: string;
  readonly width?: number;
  readonly height?: number;
}): ReservedOAuthPopup | null => {
  const popup = window.open("about:blank", input.popupName, oauthPopupFeatures(input));
  if (!popup) return null;
  // Keep opener available for the same-origin callback page. Browser
  // BroadcastChannel delivery can be partitioned in popup flows, so the
  // callback's postMessage path is the primary completion signal.
  return { popup };
};

/**
 * Open a centered popup window at `url` and resolve when the popup posts
 * an `OAuthPopupResult` back to the opener. Returns a teardown function
 * that removes the listeners, stops polling, and closes the popup window.
 *
 * Settles exactly once via one of three paths:
 *   1. `onResult`      — popup posted a message back (success or error)
 *   2. `onClosed`      — user closed the popup without completing the flow,
 *                        unless `closedPollMs` is null
 *   3. teardown called — caller cancelled programmatically
 *
 * If the popup is blocked (`window.open` returns null), invokes
 * `onOpenFailed` on the next microtask and returns a no-op teardown.
 */
export const openOAuthPopup = <TAuth>(input: OpenOAuthPopupInput<TAuth>): (() => void) => {
  if (!isHttpPopupUrl(input.url)) {
    queueMicrotask(() => input.onOpenFailed?.());
    return () => {};
  }

  let settled = false;
  let pollHandle: ReturnType<typeof setInterval> | null = null;
  const channel =
    typeof BroadcastChannel !== "undefined" ? new BroadcastChannel(input.channelName) : null;

  const onMessage = (event: MessageEvent) => {
    if (event.origin !== window.location.origin) return;
    handleResult(event.data);
  };

  // localStorage `storage` events are the reliable same-origin completion path:
  // they fire on the opener when the (same-origin) callback page writes, survive
  // the provider's COOP severing `window.opener`, and aren't lost to the popup's
  // auto-close (unlike a raced BroadcastChannel). The callback writes the result
  // under `channelName`; we read it, clean up, and settle.
  const onStorage = (event: StorageEvent) => {
    if (event.key !== input.channelName || event.newValue === null) return;
    // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: JSON.parse of a foreign storage value can throw
    try {
      // oxlint-disable-next-line executor/no-json-parse -- boundary: browser-only helper, no Effect runtime; parsing a same-origin localStorage signal
      const data: unknown = JSON.parse(event.newValue);
      // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: localStorage can throw (private mode / disabled)
      try {
        window.localStorage.removeItem(input.channelName);
      } catch {
        // best-effort cleanup
      }
      handleResult(data);
    } catch {
      // Malformed value — ignore; another channel may still deliver.
    }
  };

  const stopPolling = () => {
    if (pollHandle !== null) {
      clearInterval(pollHandle);
      pollHandle = null;
    }
  };

  /** Close the popup window if it's still open. Swallows cross-origin errors. */
  const closePopup = (popup: Window | null) => {
    if (!popup) return;
    // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: cross-origin popup state can throw and cleanup is best-effort
    try {
      if (!popup.closed) popup.close();
    } catch {
      // Cross-origin access can throw; safe to ignore.
    }
  };

  const settle = () => {
    if (settled) return;
    settled = true;
    window.removeEventListener("message", onMessage);
    window.removeEventListener("storage", onStorage);
    channel?.close();
    stopPolling();
  };

  const handleResult = (data: unknown) => {
    if (!isOAuthPopupResult<TAuth>(data) || settled) return;
    if (input.expectedSessionId && data.sessionId !== input.expectedSessionId) return;
    settle();
    input.onResult(data);
  };

  window.addEventListener("message", onMessage);
  window.addEventListener("storage", onStorage);
  if (channel) channel.onmessage = (event) => handleResult(event.data);

  const popup =
    input.reservedPopup?.popup ??
    reserveOAuthPopup({
      popupName: input.popupName,
      width: input.width,
      height: input.height,
    })?.popup ??
    null;
  if (!popup) {
    if (!settled) {
      settle();
      queueMicrotask(() => input.onOpenFailed?.());
    }
    return () => {};
  }
  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: popup navigation can fail if the browser has invalidated the handle
  try {
    popup.location.href = input.url;
  } catch {
    if (!settled) {
      settle();
      queueMicrotask(() => input.onOpenFailed?.());
    }
    return () => {};
  }

  // Some providers use COOP headers that can make a live cross-origin
  // popup look closed to the opener. Callers can disable polling and rely
  // on the explicit cancel path plus BroadcastChannel completion.
  const pollMs = input.closedPollMs === undefined ? 500 : input.closedPollMs;
  if (pollMs !== null) {
    pollHandle = setInterval(() => {
      let isClosed = false;
      // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: browser popup.closed can throw while navigating cross-origin
      try {
        isClosed = popup.closed;
      } catch {
        // Cross-origin access can throw during navigation; treat as open.
      }
      if (isClosed && !settled) {
        settle();
        input.onClosed?.();
      }
    }, pollMs);
  }

  return () => {
    if (settled) return;
    settle();
    closePopup(popup);
  };
};

// ---------------------------------------------------------------------------
// System-browser flow — used when a desktop host (Electron) opens the auth
// URL in the user's real browser. There's no shared origin, so the
// renderer polls `/api/oauth/await/:sessionId` for the result. The local
// server publishes there via `setOAuthCompletionListener` (see
// apps/local/src/serve.ts).
// ---------------------------------------------------------------------------

export type OpenOAuthSystemBrowserInput<TAuth> = {
  readonly url: string;
  readonly sessionId: string;
  readonly openExternal: (url: string) => Promise<void>;
  readonly onResult: (data: OAuthPopupResult<TAuth>) => void;
  /** Called once if the external open itself fails (URL rejected, IPC error). */
  readonly onOpenFailed?: (cause: unknown) => void;
  /** Poll cadence. Default 1000ms. */
  readonly pollMs?: number;
  /** Stop polling after this many ms with no result. Default 10 minutes. */
  readonly timeoutMs?: number;
  readonly onTimeout?: () => void;
};

const OAUTH_AWAIT_DEFAULT_POLL_MS = 1000;
const OAUTH_AWAIT_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;

export const openOAuthSystemBrowser = <TAuth>(
  input: OpenOAuthSystemBrowserInput<TAuth>,
): (() => void) => {
  let settled = false;
  let pollHandle: ReturnType<typeof setInterval> | null = null;
  let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
  const controller = new AbortController();

  const settle = () => {
    if (settled) return;
    settled = true;
    if (pollHandle !== null) clearInterval(pollHandle);
    if (timeoutHandle !== null) clearTimeout(timeoutHandle);
    controller.abort();
  };

  const poll = async () => {
    if (settled) return;
    // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch can reject for transient network errors during polling
    try {
      // The await poll is now gated like the rest of /api — carry the bearer
      // (standalone web). On desktop the connection has no client-side auth and
      // the main process injects the header instead.
      const authorization = getExecutorServerAuthorizationHeader();
      const response = await fetch(`/api/oauth/await/${encodeURIComponent(input.sessionId)}`, {
        signal: controller.signal,
        cache: "no-store",
        ...(authorization ? { headers: { authorization } } : {}),
      });
      if (!response.ok) return;
      const body = (await response.json()) as unknown;
      if (body === null || settled) return;
      if (!isOAuthPopupResult<TAuth>(body)) return;
      settle();
      input.onResult(body);
    } catch {
      // Transient — next tick will retry. AbortError after settle is also caught here.
    }
  };

  void (async () => {
    // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: openExternal is host-provided IPC, no Effect runtime in this browser-only helper
    try {
      await input.openExternal(input.url);
    } catch (cause: unknown) {
      if (settled) return;
      settle();
      input.onOpenFailed?.(cause);
    }
  })();

  pollHandle = setInterval(() => void poll(), input.pollMs ?? OAUTH_AWAIT_DEFAULT_POLL_MS);
  void poll();
  timeoutHandle = setTimeout(() => {
    if (settled) return;
    settle();
    input.onTimeout?.();
  }, input.timeoutMs ?? OAUTH_AWAIT_DEFAULT_TIMEOUT_MS);

  return () => settle();
};
