// src/lib/offline/sync.ts
import { enqueueAction, getQueuedActions, markInFlight, markSynced, markConflict, markFailed } from "./queue";

export const BACKOFF_SCHEDULE = [1000, 4000, 16000, 60000, 300000] as const;
export const MAX_ATTEMPTS = 5;

export function calculateBackoff(attempt: number): number {
  const idx = Math.min(attempt, BACKOFF_SCHEDULE.length - 1);
  return BACKOFF_SCHEDULE[idx];
}

export type Dispatcher = (actionName: string, payload: unknown, clientActionId: string) => Promise<
  | { ok: true }
  | { ok: false; conflict: string }
  | { ok: false; error: string }
>;

export interface SyncOptions {
  online: boolean;
  sleep: (ms: number) => Promise<void>;
  now?: () => number;
}

export interface SyncResult {
  synced: number;
  conflicts: number;
  failed: number;
}

// Re-entrancy guard: concurrent `online` events shouldn't kick off parallel
// sync passes that race on the same `markInFlight`/`markSynced` records.
let inFlightSync: Promise<SyncResult> | null = null;

export async function syncPending(dispatcher: Dispatcher, options: SyncOptions): Promise<SyncResult> {
  if (inFlightSync) return inFlightSync;
  inFlightSync = (async () => {
    const result: SyncResult = { synced: 0, conflicts: 0, failed: 0 };
    if (!options.online) return result;

    const actions = await getQueuedActions();
    for (const action of actions) {
      if (action.status === "conflict" || action.status === "failed") {
        // Don't auto-retry conflicts/failures; require manual user intervention
        continue;
      }
      if (action.attempts >= MAX_ATTEMPTS) {
        await markFailed(action.id, "max attempts reached");
        result.failed += 1;
        continue;
      }

      const backoff = calculateBackoff(action.attempts);
      if (action.lastAttemptAt) {
        const elapsed = (options.now?.() ?? Date.now()) - action.lastAttemptAt;
        if (elapsed < backoff) continue; // not time yet
      }

      await markInFlight(action.id);
      try {
        const dispatchResult = await dispatcher(action.actionName, action.payload, action.id);
        if (dispatchResult.ok) {
          await markSynced(action.id);
          result.synced += 1;
        } else if ("conflict" in dispatchResult) {
          await markConflict(action.id, dispatchResult.conflict);
          result.conflicts += 1;
        } else if ("error" in dispatchResult) {
          await markFailed(action.id, dispatchResult.error);
          result.failed += 1;
        }
      } catch (err) {
        // A single poisoned record (IDB closed, quota, etc.) must not abort
        // the whole sync pass. Swallow the secondary failure with a warn so
        // it stays observable in dev.
        const message = err instanceof Error ? err.message : String(err);
        try {
          await markFailed(action.id, message);
        } catch (innerErr) {
          console.warn("[offline-sync] markFailed threw for", action.id, innerErr);
        }
        result.failed += 1;
      }
    }
    return result;
  })();
  try {
    return await inFlightSync;
  } finally {
    inFlightSync = null;
  }
}

/**
 * Wire this up to navigator.onLine + 'online' event in the client.
 * Returns an unsubscribe function.
 */
export function attachSyncListener(dispatcher: Dispatcher): () => void {
  if (typeof window === "undefined") return () => {};
  const handler = () => {
    void syncPending(dispatcher, { online: navigator.onLine, sleep: (ms) => new Promise((r) => setTimeout(r, ms)) });
  };
  window.addEventListener("online", handler);
  // Also fire once on attach if online
  if (navigator.onLine) void handler();
  return () => window.removeEventListener("online", handler);
}
