import type * as Y from "yjs";
import { Awareness } from "y-protocols/awareness";
import { parseCollabRoomId } from "@pcbjam/shared";
import { connectBroadcastChannel } from "./broadcast-transport";
import { connectAwarenessBroadcast } from "./awareness-bc";

/**
 * A Y.Doc transport, normalized across backends. The reconciler/seed logic only
 * needs to know when the initial state has arrived (`whenSynced`) and how to
 * tear down (`destroy`); everything else is provider-specific.
 *
 * Providers are network libraries (MIT/ISC) linked by the GPL editor — fine, and
 * the closed PartyKit server is reached only over WSS (no linking).
 */
export interface YjsProvider {
  /**
   * Resolves once the doc holds the authoritative initial state: for network
   * providers when the server has synced; for BroadcastChannel after a short
   * settle window (a peer tab may have answered). The seed-vs-adopt decision
   * runs after this.
   */
  whenSynced(): Promise<void>;
  destroy(): void;
  /**
   * The room's Yjs awareness sidecar (presence: roster/cursor/selection —
   * collab-presence 0001). Network providers surface their own instance (the
   * server relays it); the BroadcastChannel provider ferries one over a
   * sibling channel. Absent for `none` — presence UI then renders nothing.
   */
  awareness?: Awareness;
  /**
   * Gateway transport only (load-path-rework 0003): upgrade a `passive`
   * subscription to a real y-protocol participant and resolve once the doc
   * holds the server state. Providers without it are always fully synced
   * after `whenSynced` — callers treat absence as an already-synced no-op.
   */
  activate?(): Promise<void>;
  /**
   * Gateway transport only: the doc changed server-side while this
   * subscription was passive (the sheet manager's parked-dirty flag).
   */
  onTouched?(cb: () => void): void;
}

export type ProviderKind =
  | "none"
  | "broadcastchannel"
  | "partykit"
  | "hocuspocus";

export interface ProviderConfig {
  kind: ProviderKind;
  /** Host/URL for network providers (partykit, hocuspocus). */
  endpoint?: string;
  /** Connection params (e.g. `{ token }`); see @pcbjam/shared `collabConnectParams`. */
  params?: { token?: string };
  /** BroadcastChannel only: how long to wait for a peer tab before seeding. */
  settleMs?: number;
}

// The closed sync server (apps/sync) binds the room DO as `BoardRoom`;
// partyserver routes it under the kebab-cased party name.
const PARTYKIT_PARTY = "board-room";

function requireEndpoint(config: ProviderConfig): string {
  if (!config.endpoint) {
    throw new Error(
      `collab provider "${config.kind}" requires an endpoint (VITE_YJS_ENDPOINT)`,
    );
  }
  return config.endpoint;
}

function noopProvider(): YjsProvider {
  return { whenSynced: () => Promise.resolve(), destroy: () => {} };
}

function broadcastChannelProvider(
  doc: Y.Doc,
  room: string,
  settleMs: number,
): YjsProvider {
  const transport = connectBroadcastChannel(doc, room);
  // Presence sidecar over its own channel — the doc transport stays untouched.
  const awareness = new Awareness(doc);
  const awarenessRelay = connectAwarenessBroadcast(awareness, `${room}:awareness`);
  return {
    // No server to sync with — keep the original seed-once heuristic: give a
    // peer tab a moment to answer the state query before deciding seed/adopt.
    whenSynced: () => new Promise((resolve) => setTimeout(resolve, settleMs)),
    destroy: () => {
      awarenessRelay.destroy();
      awareness.destroy();
      transport.destroy();
    },
    awareness,
  };
}

async function partyKitProvider(
  doc: Y.Doc,
  endpoint: string,
  room: string,
  params: ProviderConfig["params"],
): Promise<YjsProvider> {
  const { default: YProvider } = await import("y-partyserver/provider");
  // The provider splices the room into the WS URL verbatim, so it must travel
  // as ONE percent-encoded path segment — otherwise a docPath containing
  // `/`, ` `, `#`, `%` or `?` truncates or corrupts the room name. The sync
  // worker decodes it once at its edge; the decoded string is the canonical
  // room/DO name everywhere.
  const provider = new YProvider(endpoint, encodeURIComponent(room), doc, {
    party: PARTYKIT_PARTY,
    connect: true,
    params,
    // Default maxBackoffTime is 2.5s — a room that's down would re-dial (and
    // re-run the server's authorize round trip) every 2.5s forever, per room.
    maxBackoffTime: 30_000,
  });
  return {
    whenSynced: () =>
      new Promise<void>((resolve) => {
        if (provider.synced) return resolve();
        const onSync = (state: boolean) => {
          if (state) {
            provider.off("sync", onSync);
            resolve();
          }
        };
        provider.on("sync", onSync);
      }),
    destroy: () => provider.destroy(),
    // y-partyserver's provider carries its own awareness; the BoardRoom DO
    // (YServer) relays awareness messages as part of the protocol.
    awareness: provider.awareness,
  };
}

async function hocuspocusProvider(
  doc: Y.Doc,
  endpoint: string,
  room: string,
  params: ProviderConfig["params"],
): Promise<YjsProvider> {
  const { HocuspocusProvider } = await import("@hocuspocus/provider");
  const provider = new HocuspocusProvider({
    url: endpoint,
    name: room,
    document: doc,
    ...(params?.token ? { token: params.token } : {}),
  });
  return {
    whenSynced: () =>
      new Promise<void>((resolve) => {
        if (provider.synced) return resolve();
        const onSync = () => {
          provider.off("synced", onSync);
          resolve();
        };
        provider.on("synced", onSync);
      }),
    destroy: () => provider.destroy(),
    awareness: provider.awareness ?? undefined,
  };
}

/**
 * Connect a Y.Doc to the env-selected provider. Network libraries are imported
 * lazily so a deployment only ships the one it uses.
 *
 * `passive` (gateway transport only): register interest without demanding doc
 * state — the sheet manager's warm pool. Other providers ignore it and stay
 * fully synced, a harmless superset.
 */
export async function connectProvider(
  doc: Y.Doc,
  config: ProviderConfig,
  opts: { room: string; passive?: boolean },
): Promise<YjsProvider> {
  switch (config.kind) {
    case "broadcastchannel":
      return broadcastChannelProvider(doc, opts.room, config.settleMs ?? 300);
    case "partykit": {
      // Load-path-rework 0003: board/presence rooms ride the per-project
      // gateway socket (one ws per project instead of one per room). The
      // legacy per-room dial stays only for room ids that don't parse — an
      // operator/debug shape the gateway can't address.
      const parsed = parseCollabRoomId(opts.room);
      if (parsed) {
        const { GatewayDocFacade } = await import("./gateway");
        return new GatewayDocFacade(doc, {
          endpoint: requireEndpoint(config),
          scopeId: parsed.scopeId,
          projectId: parsed.projectId,
          docPath: parsed.docPath,
          token: config.params?.token,
          passive: opts.passive,
        });
      }
      return partyKitProvider(doc, requireEndpoint(config), opts.room, config.params);
    }
    case "hocuspocus":
      return hocuspocusProvider(doc, requireEndpoint(config), opts.room, config.params);
    case "none":
      return noopProvider();
    default: {
      const _exhaustive: never = config.kind;
      return _exhaustive ?? noopProvider();
    }
  }
}
