/**
 * Cloud-prefs schema migrations and conflict-merge, isolated from
 * cloud-prefs-sync.ts so they stay testable without importing the full sync
 * runtime (which transitively pulls in `import.meta.env.DEV` via
 * `@/services/clerk` → proxy.ts and fails outside a Vite build).
 *
 * Each migration is a pure function from blob → blob. The map is keyed by
 * the TARGET schema version (so MIGRATIONS[N] runs when going from N-1 → N).
 */

import { findFullyDisabledCategories, type FeedsByCategory } from '@/services/source-cap';

/**
 * Apply all migrations from `fromVersion + 1` up through `toVersion`
 * inclusive. Pure function — no I/O. Caller controls migrations map and
 * feeds context. Extracted for direct testing without pulling in the
 * cloud-prefs-sync runtime (which has a Vite-env transitive import).
 */
export function applyMigrationChain(
  data: Record<string, unknown>,
  fromVersion: number,
  toVersion: number,
  migrations: Record<number, (data: Record<string, unknown>) => Record<string, unknown>>,
): Record<string, unknown> {
  let result = data;
  for (let v = fromVersion + 1; v <= toVersion; v++) {
    result = migrations[v]?.(result) ?? result;
  }
  return result;
}

/**
 * Conflict-resolution merge for cloud-prefs sync.
 *
 * When a POST to /api/user-prefs hits a 409 (the cloud row advanced under
 * us), the local edits the user JUST made must not be discarded. The old
 * behaviour fetched the fresh cloud row and overwrote localStorage with it
 * wholesale — silently destroying, e.g., a watchlist the user typed seconds
 * earlier. This merge resolves the conflict without data loss:
 *
 *   - Start from the fresh cloud blob (so a concurrent change from another
 *     device survives).
 *   - Overlay the keys the user changed locally since the last clean upload
 *     (`dirtyKeys`): a dirty key present in `localBlob` → the local value
 *     wins; a dirty key ABSENT from `localBlob` → the user removed it
 *     locally → drop it from the merge so the removal sticks.
 *
 * Pure function — no I/O. `cloudData` is the migrated cloud blob, `localBlob`
 * is the current localStorage snapshot, `dirtyKeys` is the set of sync keys
 * mutated locally since the last clean upload. Extracted here (not in
 * cloud-prefs-sync.ts) so it stays unit-testable without the sync runtime.
 */
export function mergeCloudWithLocalDirty(
  cloudData: Record<string, unknown>,
  localBlob: Record<string, string>,
  dirtyKeys: Iterable<string>,
): Record<string, string> {
  const merged: Record<string, string> = {};
  for (const [key, val] of Object.entries(cloudData)) {
    if (typeof val === 'string') merged[key] = val;
  }
  for (const key of dirtyKeys) {
    if (Object.prototype.hasOwnProperty.call(localBlob, key)) {
      merged[key] = localBlob[key]!;
    } else {
      delete merged[key];
    }
  }
  return merged;
}

/**
 * After a successful upload, decide which dirty keys are now durably synced
 * and can be cleared — NOT the whole set.
 *
 * A user can mutate another pref *while the POST is in flight*: the setItem
 * patch marks it dirty, but it was never in `postedBlob`. Blanket-clearing
 * the dirty set would drop that tracking, so a subsequent 409 would see an
 * empty dirty set and mergeCloudWithLocalDirty would let the cloud blob
 * clobber the just-made edit — reintroducing the exact data-loss bug the
 * dirty set exists to prevent.
 *
 * A key is "settled" iff the value the server accepted (`postedBlob`) still
 * equals the current local value (`localBlob`). Absence counts as null on
 * both sides, so a synced *removal* settles too. A key changed mid-flight,
 * or dirtied mid-flight and absent from `postedBlob`, fails the equality
 * check and is NOT returned — it stays dirty for the next upload.
 *
 * Pure function — no I/O. Returns the subset of `dirtyKeys` safe to clear.
 */
export function settledDirtyKeys(
  postedBlob: Record<string, string>,
  localBlob: Record<string, string>,
  dirtyKeys: Iterable<string>,
): string[] {
  const settled: string[] = [];
  for (const key of dirtyKeys) {
    const posted = Object.prototype.hasOwnProperty.call(postedBlob, key) ? postedBlob[key]! : null;
    const local = Object.prototype.hasOwnProperty.call(localBlob, key) ? localBlob[key]! : null;
    if (posted === local) settled.push(key);
  }
  return settled;
}

export function parsePersistedDirtyKeys(
  raw: string | null,
  allowedKeys: Iterable<string>,
  expectedUserId: string,
): string[] {
  if (!raw) return [];

  let parsed: unknown;
  try { parsed = JSON.parse(raw); } catch { return []; }
  if (
    !parsed ||
    typeof parsed !== 'object' ||
    (parsed as { userId?: unknown }).userId !== expectedUserId ||
    !Array.isArray((parsed as { keys?: unknown }).keys)
  ) {
    return [];
  }

  const allowed = new Set(allowedKeys);
  const seen = new Set<string>();
  const keys: string[] = [];
  for (const key of (parsed as { keys: unknown[] }).keys) {
    if (typeof key !== 'string' || !allowed.has(key) || seen.has(key)) continue;
    seen.add(key);
    keys.push(key);
  }
  return keys;
}

/**
 * Schema-2 migrations map. Used both inline by cloud-prefs-sync.ts (against
 * the variant-aware FEEDS) and by tests (against fixture FEEDS).
 */
export function buildMigrations(
  feedsByCategory: FeedsByCategory,
): Record<number, (data: Record<string, unknown>) => Record<string, unknown>> {
  return {
    2: (data) => migrateDisabledFeedsV2(data, feedsByCategory),
  };
}

/**
 * Schema-2 migration body, kept separate for direct unit testing.
 *
 * Schema 2 (2026-05-01): one-shot recovery for the v1 free-tier source-cap
 * bug. The pre-PR-3521 alphabetical-slice cap auto-disabled every source
 * past position 80 alphabetically, leaving entire late-alphabet categories
 * (Layoffs, Semiconductors, IPO, Funding, Product Hunt, …) with 100% of
 * their feeds in `disabledFeeds`. PR #3521 added a per-origin localStorage
 * migration to recover this, but cloud-prefs sync re-poisoned origins
 * every load by overwriting localStorage with the still-bad cloud blob —
 * the recovery had to live at the cloud-data layer to be permanent.
 *
 * This migration runs ONCE per cloud row (gated by schemaVersion < 2),
 * detects categories where 100% of sources are in `disabledFeeds`, and
 * re-enables them. After the migration completes, schemaVersion bumps to
 * 2 and subsequent sync pulls skip recovery — so a user who explicitly
 * disables every source in a category POST-migration keeps that
 * preference forever. The 100%-disabled-category heuristic is targeted
 * enough that explicit single-source disabling is preserved.
 *
 * The recovery uses the variant-aware FEEDS passed in by the caller; the
 * cloud blob is variant-scoped (per /api/user-prefs?variant=...) so the
 * caller-supplied FEEDS already matches the row's variant.
 */
export function migrateDisabledFeedsV2(
  data: Record<string, unknown>,
  feedsByCategory: FeedsByCategory,
): Record<string, unknown> {
  const raw = data['worldmonitor-disabled-feeds'];
  if (typeof raw !== 'string') return data;
  let parsed: unknown;
  try { parsed = JSON.parse(raw); } catch { return data; }
  if (!Array.isArray(parsed) || parsed.length === 0) return data;

  const disabledStrings = parsed.filter((n): n is string => typeof n === 'string');
  const recoverable = findFullyDisabledCategories(feedsByCategory, new Set(disabledStrings));
  if (recoverable.length === 0) return data;

  const recoveredSet = new Set(recoverable);
  const cleaned = parsed.filter(
    (n) => typeof n !== 'string' || !recoveredSet.has(n),
  );
  console.log(
    `[cloud-prefs] schema-2 migration: re-enabled ${recoverable.length} source(s) from fully-disabled categories`,
  );
  return { ...data, 'worldmonitor-disabled-feeds': JSON.stringify(cleaned) };
}
