// Cloud sync layer for the shot list.
//
// Behaviour summary:
//   - Source of truth on the wire: Supabase `shot_lists` table (one row
//     per project, owner-scoped via RLS).
//   - localStorage stays the snappy in-page cache. Every write to the
//     index or a per-project state is mirrored to Supabase asynchronously
//     when signed in, and silently no-ops when signed out.
//   - On sign-in we reconcile: pull all server rows, compare updated_at
//     against the local cache, and last-write-wins per project id. New
//     server rows land in the cache; new local rows get pushed.
//
// All helpers are exposed on `window` so storage.jsx and root.jsx can
// call them without an import dance.

const SYNC_FLAG_KEY  = 'pp-shotlist-cloud-synced-for';
const SYNC_TABLE     = 'shot_lists';
const STORAGE_BUCKET = 'shot-list-images';
// Substring used to identify our own public URLs when we need to map a
// URL back to a storage object path (for delete operations).
const PUBLIC_URL_PREFIX = '/storage/v1/object/public/' + STORAGE_BUCKET + '/';

// "Am I logged in?" — derives the active user id from the Supabase
// client, falling back to null when the client isn't loaded or the
// session is missing.
function ppGetUserId() {
  try {
    const c = window.supabaseClient;
    if (!c) return null;
    const u = c.auth && c.auth._lastSession?.user || null;
    if (u && u.id) return u.id;
  } catch (e) {}
  return null;
}

// Async variant — uses getSession so we always get a current answer.
async function ppGetUserIdAsync() {
  try {
    const c = window.supabaseClient;
    if (!c) return null;
    const { data } = await c.auth.getSession();
    return data?.session?.user?.id || null;
  } catch (e) { return null; }
}

// Turn a metadata-index row + per-project state into a flat row for
// the Supabase `shot_lists` table. The state column carries the full
// editor JSON so the schema can evolve without DB migrations.
function ppRowFromLocal(meta, state) {
  return {
    id: meta.id,
    title: meta.title || 'Untitled Shot List',
    favourite: !!meta.favourite,
    archived: !!meta.archived,
    deleted_at: meta.deletedAt ? new Date(meta.deletedAt).toISOString() : null,
    shot_count: meta.shotCount || 0,
    state: state || {},
    // updated_at is set by the DB trigger; we send a hint anyway so the
    // server can compare against our local lastModified during merge.
    updated_at: meta.updatedAt ? new Date(meta.updatedAt).toISOString() : new Date().toISOString(),
  };
}

// Reverse: turn a Supabase row into a (meta, state) pair we can drop
// into localStorage. The DB columns map directly back.
function ppLocalFromRow(row) {
  const meta = {
    id: row.id,
    title: row.title || 'Untitled Shot List',
    favourite: !!row.favourite,
    archived: !!row.archived,
    deletedAt: row.deleted_at ? new Date(row.deleted_at).getTime() : undefined,
    shotCount: row.shot_count || 0,
    createdAt: row.created_at ? new Date(row.created_at).getTime() : Date.now(),
    updatedAt: row.updated_at ? new Date(row.updated_at).getTime() : Date.now(),
  };
  if (meta.deletedAt === undefined) delete meta.deletedAt;
  return { meta, state: row.state || {} };
}

// Push a single project (meta + state) to Supabase. Fire-and-forget — we
// don't block the editor on network. Errors are logged so the user can
// see them in the console without a popup interrupting their editing.
async function ppPushOne(id) {
  const uid = await ppGetUserIdAsync();
  if (!uid) return;
  const c = window.supabaseClient;
  if (!c) return;
  const meta = (window.loadProjectsIndex() || []).find(m => m.id === id);
  if (!meta) return;
  let state = {};
  try {
    const raw = localStorage.getItem(window.projectKey(id));
    if (raw) state = JSON.parse(raw);
  } catch (e) {}
  // Hoist any inline data: URLs out to Supabase Storage and replace with
  // public URLs. Keeps the row payload small + uniform; legacy projects
  // that used the old inline-image format get migrated transparently.
  if (state && Array.isArray(state.scenes)) {
    state = await ppMigrateStateImages(id, state);
    try { localStorage.setItem(window.projectKey(id), JSON.stringify(state)); } catch (e) {}
  }
  const row = ppRowFromLocal(meta, state);
  row.user_id = uid;
  // Capture the server-assigned updated_at so concurrent-edit detection can tell
  // our own writes apart from another device's.
  const { data, error } = await c.from(SYNC_TABLE).upsert(row, { onConflict: 'id' }).select('updated_at').single();
  if (error) { console.warn('cloud push failed', id, error.message); return; }
  if (data && data.updated_at) {
    window.ppLastPushedAt = window.ppLastPushedAt || {};
    window.ppLastPushedAt[id] = new Date(data.updated_at).getTime();
  }
}

// Server updated_at (ms) for a project — used to detect that another device
// changed it since we loaded/last-pushed. Owner-only via RLS; null if missing.
async function ppGetRemoteUpdatedAt(id) {
  const c = window.supabaseClient;
  if (!c) return null;
  const { data, error } = await c.from(SYNC_TABLE).select('updated_at').eq('id', id).maybeSingle();
  if (error || !data) return null;
  return data.updated_at ? new Date(data.updated_at).getTime() : null;
}

// Version history (safety net). Owner-only via RLS on shot_list_versions.
async function ppListVersions(id) {
  const c = window.supabaseClient;
  if (!c) return [];
  const { data, error } = await c.from('shot_list_versions')
    .select('id, created_at').eq('shot_list_id', id).order('created_at', { ascending: false });
  if (error) { console.warn('list versions failed', error.message); return []; }
  return data || [];
}
async function ppGetVersion(versionId) {
  const c = window.supabaseClient;
  if (!c) return null;
  const { data, error } = await c.from('shot_list_versions')
    .select('state').eq('id', versionId).maybeSingle();
  if (error || !data) return null;
  return data.state || null;
}

// Permanently remove a project from the cloud. Called when the local
// deleteProjectStorage is invoked OR when the 30-day trash purge runs.
async function ppDeleteOne(id) {
  const uid = await ppGetUserIdAsync();
  if (!uid) return;
  const c = window.supabaseClient;
  if (!c) return;
  const { error } = await c.from(SYNC_TABLE).delete().eq('id', id);
  if (error) console.warn('cloud delete failed', id, error.message);
}

// Pull every row the signed-in user owns and merge into localStorage.
//   - row present remotely but not locally → write the row locally
//   - row present in both → keep whichever has the newer updated_at
//   - row present locally but not remotely → push the local one up
//
// Returns a summary so callers can show a toast or log it.
async function ppReconcileOnSignIn() {
  const uid = await ppGetUserIdAsync();
  if (!uid) return { ok: false, reason: 'not-signed-in' };
  const c = window.supabaseClient;
  if (!c) return { ok: false, reason: 'no-client' };

  // 1) Pull everything for this user
  const { data: rows, error } = await c.from(SYNC_TABLE).select('*').eq('user_id', uid);
  if (error) {
    console.warn('cloud pull failed', error.message);
    return { ok: false, reason: 'pull-error', error: error.message };
  }
  const remoteById = new Map((rows || []).map(r => [r.id, r]));

  // 2) Walk the local index and decide per id
  const localIdx = window.loadProjectsIndex() || [];
  const localById = new Map(localIdx.map(m => [m.id, m]));
  const summary = { pulled: 0, pushed: 0, kept: 0 };

  // a) For every remote row, decide vs local
  for (const row of (rows || [])) {
    const { meta, state } = ppLocalFromRow(row);
    const localMeta = localById.get(row.id);
    if (!localMeta) {
      // Server has it, we don't — write to local cache.
      localIdx.push(meta);
      try { localStorage.setItem(window.projectKey(row.id), JSON.stringify(state)); } catch (e) {}
      summary.pulled++;
      continue;
    }
    if ((meta.updatedAt || 0) > (localMeta.updatedAt || 0)) {
      // Server is newer — overwrite local.
      const idx = localIdx.findIndex(m => m.id === row.id);
      if (idx >= 0) localIdx[idx] = meta;
      try { localStorage.setItem(window.projectKey(row.id), JSON.stringify(state)); } catch (e) {}
      summary.pulled++;
    } else if ((localMeta.updatedAt || 0) > (meta.updatedAt || 0)) {
      // Local is newer — push later (after the loop).
      summary.pushed++;
    } else {
      summary.kept++;
    }
  }
  window.saveProjectsIndex(localIdx);

  // b) For every local row not on the server, push it.
  const pushes = [];
  for (const m of localIdx) {
    const remote = remoteById.get(m.id);
    if (!remote) {
      pushes.push(ppPushOne(m.id));
      summary.pushed++;
    } else if ((m.updatedAt || 0) > new Date(remote.updated_at).getTime()) {
      pushes.push(ppPushOne(m.id));
    }
  }
  await Promise.allSettled(pushes);

  // Remember that we've reconciled for this user so we don't re-run on
  // every page load — pushes during normal use keep things in sync.
  try { localStorage.setItem(SYNC_FLAG_KEY, uid); } catch (e) {}
  return { ok: true, ...summary };
}

// Has this user already been reconciled in this browser?
function ppAlreadyReconciled(uid) {
  try { return localStorage.getItem(SYNC_FLAG_KEY) === uid; }
  catch (e) { return false; }
}

// Clear the "already reconciled" flag — used when the user signs out, so
// the next sign-in triggers a fresh reconcile (in case data drifted).
function ppClearSyncFlag() {
  try { localStorage.removeItem(SYNC_FLAG_KEY); } catch (e) {}
}

// ────────────────────────────────────────────────────────────────────────
// Image storage — Supabase Storage bucket "shot-list-images"
// ────────────────────────────────────────────────────────────────────────

// Downscale + re-encode an image File before upload. Caps the longest edge and
// re-encodes to WebP (JPEG fallback) so cloud storage, egress, AND the
// localStorage data-URL fallback all stay small (a full-res phone photo of
// ~4 MB typically becomes ~200–400 KB). EXIF orientation is honoured. Returns a
// Blob, or the original file if anything fails or it wouldn't actually shrink.
async function ppDownscaleImage(file, opts) {
  opts = opts || {};
  const maxEdge = opts.maxEdge || 2000;
  const quality = opts.quality || 0.82;
  try {
    if (!file || !file.type || !file.type.startsWith('image/')) return file;
    if (file.type === 'image/gif') return file;            // don't flatten animation
    if (typeof createImageBitmap !== 'function') return file;

    let bitmap;
    try { bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' }); }
    catch (e) { bitmap = await createImageBitmap(file); }

    const w = bitmap.width, h = bitmap.height;
    const scale = Math.min(1, maxEdge / Math.max(w, h));
    const tw = Math.max(1, Math.round(w * scale));
    const th = Math.max(1, Math.round(h * scale));

    const canvas = document.createElement('canvas');
    canvas.width = tw; canvas.height = th;
    canvas.getContext('2d').drawImage(bitmap, 0, 0, tw, th);
    if (bitmap.close) bitmap.close();

    // Prefer WebP; fall back to JPEG if the browser ignored the requested type.
    let blob = await new Promise((r) => canvas.toBlob(r, 'image/webp', quality));
    if (!blob || blob.type !== 'image/webp') {
      blob = await new Promise((r) => canvas.toBlob(r, 'image/jpeg', quality));
    }
    if (!blob) return file;
    // If it was already small and we didn't shrink it, keep the original bytes.
    if (scale === 1 && blob.size >= file.size) return file;
    return blob;
  } catch (e) {
    console.warn('image downscale failed; uploading original', e);
    return file;
  }
}

// Resolve a File / Blob / data-URL to a (blob, ext) pair we can upload.
async function ppResolveBlob(fileOrDataUrl) {
  if (fileOrDataUrl instanceof Blob) {
    const t = (fileOrDataUrl.type || '').split('/')[1] || 'jpg';
    return { blob: fileOrDataUrl, ext: t === 'jpeg' ? 'jpg' : t.split('+')[0] };
  }
  if (typeof fileOrDataUrl === 'string' && fileOrDataUrl.startsWith('data:')) {
    const m = fileOrDataUrl.match(/^data:image\/(\w+);/);
    const ext = m ? (m[1] === 'jpeg' ? 'jpg' : m[1]) : 'jpg';
    const r = await fetch(fileOrDataUrl);
    const blob = await r.blob();
    return { blob, ext };
  }
  return null;
}

// Upload a single image for a (projectId, shotId). Returns the public URL,
// or null on failure / signed-out / Supabase unavailable.
async function ppUploadImage(projectId, shotId, fileOrDataUrl) {
  const uid = await ppGetUserIdAsync();
  if (!uid) return null;
  const c = window.supabaseClient;
  if (!c) return null;
  const resolved = await ppResolveBlob(fileOrDataUrl);
  if (!resolved) return null;
  // Random suffix so a replace-image action doesn't collide with the
  // previous file's path (and we can delete the old one explicitly).
  const rand = Math.random().toString(36).slice(2, 8);
  const path = `${uid}/${projectId}/${shotId}-${rand}.${resolved.ext}`;
  const { error } = await c.storage.from(STORAGE_BUCKET).upload(path, resolved.blob, {
    cacheControl: '3600',
    upsert: false,
    contentType: resolved.blob.type || 'image/jpeg',
  });
  if (error) {
    console.warn('image upload failed', path, error.message);
    return null;
  }
  const { data } = c.storage.from(STORAGE_BUCKET).getPublicUrl(path);
  return data?.publicUrl || null;
}

// Delete a single image given its public URL or storage path. Only acts
// on files inside the current user's folder (the RLS policy would also
// block it, but we short-circuit to avoid a needless round-trip).
async function ppDeleteImage(urlOrPath) {
  if (!urlOrPath || typeof urlOrPath !== 'string') return;
  // Only delete proper http(s) cloud paths — never touch data: URLs.
  if (urlOrPath.startsWith('data:')) return;
  const uid = await ppGetUserIdAsync();
  if (!uid) return;
  const c = window.supabaseClient;
  if (!c) return;
  let path = urlOrPath;
  const i = urlOrPath.indexOf(PUBLIC_URL_PREFIX);
  if (i >= 0) path = urlOrPath.slice(i + PUBLIC_URL_PREFIX.length);
  if (!path.startsWith(uid + '/')) return;     // not ours — bail
  const { error } = await c.storage.from(STORAGE_BUCKET).remove([path]);
  if (error) console.warn('image delete failed', path, error.message);
}

// Hard-delete every file under <user_id>/<projectId>/ — called when a
// project is permanently removed (TTL purge or "Delete forever"). Uses
// list() to find paths, then remove() in a single batch.
async function ppDeleteProjectImages(projectId) {
  const uid = await ppGetUserIdAsync();
  if (!uid) return;
  const c = window.supabaseClient;
  if (!c) return;
  const folder = `${uid}/${projectId}`;
  const { data, error } = await c.storage.from(STORAGE_BUCKET).list(folder, { limit: 1000 });
  if (error) { console.warn('list-for-delete failed', folder, error.message); return; }
  if (!data || data.length === 0) return;
  const paths = data.map(f => `${folder}/${f.name}`);
  const { error: rmErr } = await c.storage.from(STORAGE_BUCKET).remove(paths);
  if (rmErr) console.warn('project images delete failed', folder, rmErr.message);
}

// Walk a per-project state and hoist any inline data: URLs out to storage,
// rewriting shot.img to the returned public URL. Returns the (possibly
// mutated) state. Safe to call repeatedly — already-URL fields are skipped.
async function ppMigrateStateImages(projectId, state) {
  if (!state || !Array.isArray(state.scenes)) return state;
  const uid = await ppGetUserIdAsync();
  if (!uid) return state;
  const tasks = [];
  for (const scene of state.scenes) {
    if (!Array.isArray(scene.shots)) continue;
    for (const shot of scene.shots) {
      if (typeof shot.img === 'string' && shot.img.startsWith('data:')) {
        tasks.push((async () => {
          const url = await ppUploadImage(projectId, shot.id, shot.img);
          if (url) shot.img = url;
        })());
      }
    }
  }
  if (tasks.length > 0) await Promise.allSettled(tasks);
  return state;
}

// Listen for sign-in / sign-out events so cloud state stays in sync mid-
// session (e.g. user signs in from the shot list page itself). Wait until
// the Supabase client is on window, then attach. On SIGNED_IN: reconcile.
// On SIGNED_OUT: drop the synced-for flag so the next sign-in re-runs the
// reconcile (in case data drifted).
function ppInstallAuthListener() {
  const c = window.supabaseClient;
  if (!c) return false;
  c.auth.onAuthStateChange(async (event, session) => {
    if (event === 'SIGNED_IN' && session?.user?.id) {
      if (!ppAlreadyReconciled(session.user.id)) {
        try {
          const result = await ppReconcileOnSignIn();
          console.log('shot list cloud sync:', result);
        } catch (e) { console.warn('reconcile after sign-in failed', e); }
      }
    } else if (event === 'SIGNED_OUT') {
      ppClearSyncFlag();
    }
  });
  return true;
}
// Try immediately, retry briefly if the client isn't ready yet (auth.jsx
// might still be initializing).
if (!ppInstallAuthListener()) {
  const t = setInterval(() => { if (ppInstallAuthListener()) clearInterval(t); }, 100);
  setTimeout(() => clearInterval(t), 5000);
}

Object.assign(window, {
  ppGetUserId, ppGetUserIdAsync,
  ppPushOne, ppDeleteOne,
  ppReconcileOnSignIn, ppAlreadyReconciled, ppClearSyncFlag,
  ppUploadImage, ppDeleteImage, ppDeleteProjectImages, ppMigrateStateImages,
  ppGetRemoteUpdatedAt, ppListVersions, ppGetVersion, ppDownscaleImage,
});
