// Notequick data layer — Supabase CRUD, markdown helpers, realtime subscriber.

// Bootstrap defaults (used until Supabase responds).
const DEFAULT_NOTEBOOKS = [
  { id: 'inbox',    name: 'Inbox',          color: '#8a8780' },
  { id: 'work',     name: 'Work',           color: '#1d4ed8' },
  { id: 'personal', name: 'Personal',       color: '#15803d' },
  { id: 'research', name: 'Research',       color: '#9333ea' },
  { id: 'home',     name: 'Apartment hunt', color: '#c2410c' },
];

const DEFAULT_TAGS = [
  { id: 'meeting',   name: 'meeting' },
  { id: 'idea',      name: 'idea' },
  { id: 'followup',  name: 'follow-up' },
  { id: 'reference', name: 'reference' },
  { id: 'journal',   name: 'journal' },
  { id: 'decision',  name: 'decision' },
];

const NOTEBOOK_PALETTE = [
  '#1d4ed8', '#15803d', '#9333ea', '#c2410c', '#0ea5e9',
  '#db2777', '#65a30d', '#ea580c', '#0891b2', '#7c3aed',
];

if (!window.supabase || typeof window.supabase.createClient !== 'function') {
  throw new Error('Supabase JS client failed to load. Check <script> tags in index.html.');
}
if (!window.NQ_CONFIG || !window.NQ_CONFIG.SUPABASE_URL) {
  throw new Error('Missing NQ_CONFIG — check config.js.');
}
// Auth tokens are kept in sessionStorage rather than the default localStorage,
// so the session does NOT survive closing the browser/tab — reopening Notequick
// requires signing in and clearing the TOTP challenge again. This narrows the
// window in which a left-open or shared machine exposes the account. Within an
// open session, the idle timeout below adds a second backstop.
const __authStorage =
  (typeof window !== 'undefined' && window.sessionStorage) ? window.sessionStorage : undefined;

const __sb = window.supabase.createClient(
  window.NQ_CONFIG.SUPABASE_URL,
  window.NQ_CONFIG.SUPABASE_ANON_KEY,
  {
    auth: {
      persistSession: true,
      autoRefreshToken: true,
      detectSessionInUrl: false,
      storage: __authStorage,
    },
  },
);

// ── idle auto-sign-out ───────────────────────────────────────────────────────
// Signs the user out after a stretch of no interaction, so an unattended
// session doesn't stay unlocked indefinitely. Returns a cleanup function.
const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes

function startIdleSignout(onSignedOut, timeoutMs = IDLE_TIMEOUT_MS) {
  let timer = null;
  const events = ['mousedown', 'keydown', 'touchstart', 'scroll', 'visibilitychange'];

  const fire = async () => {
    try { await signOut(); } catch (_) { /* best effort */ }
    if (typeof onSignedOut === 'function') onSignedOut();
  };
  const reset = () => {
    if (document.visibilityState === 'hidden') return; // don't reset while tab is backgrounded
    if (timer) clearTimeout(timer);
    timer = setTimeout(fire, timeoutMs);
  };

  for (const ev of events) window.addEventListener(ev, reset, { passive: true });
  reset();

  return () => {
    if (timer) clearTimeout(timer);
    for (const ev of events) window.removeEventListener(ev, reset);
  };
}

// ── password strength ────────────────────────────────────────────────────────
// Enforced client-side at sign-up. Supabase can also enforce server-side
// minimums in Auth settings; this gives immediate feedback and a sane floor.
function validatePasswordStrength(pw) {
  const p = String(pw || '');
  if (p.length < 10) return 'Use at least 10 characters.';
  if (!/[a-z]/.test(p) || !/[A-Z]/.test(p)) return 'Mix uppercase and lowercase letters.';
  if (!/[0-9]/.test(p)) return 'Include at least one number.';
  return null; // ok
}

// ─── auth ───────────────────────────────────────────────────────────────────
// Returns the current Supabase user, or null.
async function getCurrentUser() {
  const { data } = await __sb.auth.getUser();
  return data?.user || null;
}

// Returns { current, next } AALs. `next === 'aal2'` while `current === 'aal1'`
// means the user has TOTP enrolled but hasn't cleared a challenge this session.
async function getAALStatus() {
  const { data, error } = await __sb.auth.mfa.getAuthenticatorAssuranceLevel();
  if (error) throw error;
  return data || { currentLevel: null, nextLevel: null };
}

async function listMFAFactors() {
  const { data, error } = await __sb.auth.mfa.listFactors();
  if (error) throw error;
  // `totp` is the array of verified TOTP factors.
  return data || { all: [], totp: [] };
}

async function signInWithPassword(email, password) {
  const { data, error } = await __sb.auth.signInWithPassword({ email, password });
  if (error) throw error;
  return data;
}

async function signUpWithPassword(email, password) {
  const { data, error } = await __sb.auth.signUp({ email, password });
  if (error) throw error;
  return data;
}

async function signOut() {
  // Drop any unverified MFA factor so the next sign-in doesn't see a stale one.
  try {
    const { totp } = await listMFAFactors();
    for (const f of totp || []) {
      if (f.status !== 'verified') {
        await __sb.auth.mfa.unenroll({ factorId: f.id }).catch(() => {});
      }
    }
  } catch (_) { /* best effort */ }
  await __sb.auth.signOut();
}

// Enroll a TOTP factor. Returns { factorId, qr, secret, uri } for the UI to
// render. Caller must follow up with verifyTOTPEnrollment().
async function enrollTOTP() {
  // Clean up any half-completed enrollment from a prior attempt so we don't
  // accumulate orphan factors.
  try {
    const { totp } = await listMFAFactors();
    for (const f of totp || []) {
      if (f.status !== 'verified') {
        await __sb.auth.mfa.unenroll({ factorId: f.id }).catch(() => {});
      }
    }
  } catch (_) { /* best effort */ }

  const { data, error } = await __sb.auth.mfa.enroll({
    factorType: 'totp',
    friendlyName: 'Notequick ' + new Date().toISOString().slice(0, 10),
  });
  if (error) throw error;
  return {
    factorId: data.id,
    qr:       data.totp?.qr_code || '',
    secret:   data.totp?.secret  || '',
    uri:      data.totp?.uri     || '',
  };
}

// Verify the 6-digit code from the user's authenticator app. On success the
// session is upgraded to AAL2 automatically.
async function verifyTOTPEnrollment(factorId, code) {
  const ch = await __sb.auth.mfa.challenge({ factorId });
  if (ch.error) throw ch.error;
  const { data, error } = await __sb.auth.mfa.verify({
    factorId, challengeId: ch.data.id, code,
  });
  if (error) throw error;
  return data;
}

// For an existing user signing in with a previously-verified TOTP factor.
async function challengeAndVerifyTOTP(factorId, code) {
  const ch = await __sb.auth.mfa.challenge({ factorId });
  if (ch.error) throw ch.error;
  const { data, error } = await __sb.auth.mfa.verify({
    factorId, challengeId: ch.data.id, code,
  });
  if (error) throw error;
  return data;
}

function onAuthStateChange(handler) {
  const { data } = __sb.auth.onAuthStateChange((event, session) => handler(event, session));
  return () => data.subscription.unsubscribe();
}

// Seed default notebooks/tags for a brand-new user. Idempotent — uses upsert.
async function seedDefaultsForUser() {
  const [nbRes, tagRes] = await Promise.all([
    __sb.from('notebooks').select('id').limit(1),
    __sb.from('tags').select('id').limit(1),
  ]);
  if ((nbRes.data || []).length === 0) {
    await __sb.from('notebooks').upsert(
      DEFAULT_NOTEBOOKS.map(n => ({ id: n.id, name: n.name, color: n.color })),
      { onConflict: 'id,user_id', ignoreDuplicates: true },
    );
  }
  if ((tagRes.data || []).length === 0) {
    await __sb.from('tags').upsert(
      DEFAULT_TAGS.map(t => ({ id: t.id, name: t.name })),
      { onConflict: 'id,user_id', ignoreDuplicates: true },
    );
  }
}

// ─── helpers ────────────────────────────────────────────────────────────────
function kindOf(mime) {
  if (!mime) return 'file';
  if (mime.startsWith('image/'))   return 'image';
  if (mime.startsWith('video/'))   return 'video';
  if (mime === 'application/pdf')  return 'pdf';
  return 'file';
}

function humanSize(n) {
  if (n == null) return '';
  if (n < 1024)         return n + ' B';
  if (n < 1024 * 1024)  return (n / 1024).toFixed(0) + ' KB';
  return (n / (1024 * 1024)).toFixed(1) + ' MB';
}

// The bucket is private (AAL2-gated), so callers must mint a short-lived
// signed URL when they want to render or download an attachment. Returns ''
// if the caller's session can't access the object.
async function getSignedFileUrl(path, expiresSec = 60 * 60) {
  const { data, error } = await __sb.storage
    .from(window.NQ_CONFIG.STORAGE_BUCKET)
    .createSignedUrl(path, expiresSec);
  if (error) return '';
  return data?.signedUrl || '';
}

function slugify(s) {
  return String(s).toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .slice(0, 32) || ('id-' + Math.random().toString(36).slice(2, 7));
}

function pickColor(usedColors) {
  for (const c of NOTEBOOK_PALETTE) {
    if (!usedColors.includes(c)) return c;
  }
  return NOTEBOOK_PALETTE[Math.floor(Math.random() * NOTEBOOK_PALETTE.length)];
}

// ─── markdown ───────────────────────────────────────────────────────────────
if (window.marked && typeof window.marked.use === 'function') {
  window.marked.use({ breaks: true, gfm: true });
}

// Register the link-hardening hook once. Every sanitized <a> is forced to open
// in a new tab with rel="noopener noreferrer" so linked pages can't reach back
// into this one via window.opener. DOMPurify already strips javascript:/data:
// script URLs, inline event handlers, and <script>/<style> by default.
if (window.DOMPurify && typeof window.DOMPurify.addHook === 'function' && !window.__nqPurifyHooked) {
  window.__nqPurifyHooked = true;
  window.DOMPurify.addHook('afterSanitizeAttributes', (node) => {
    if (node.tagName === 'A' && node.getAttribute('href')) {
      node.setAttribute('target', '_blank');
      node.setAttribute('rel', 'noopener noreferrer');
    }
  });
}

// Sanitizer config for note/comment markdown: forbid inline styles (no CSS-based
// exfiltration or clickjacking via attacker-supplied style attrs) and forbid
// raw <iframe>/<object>/<embed>/<form>.
const __MD_PURIFY_CFG = {
  USE_PROFILES: { html: true },
  ADD_ATTR: ['target'],
  FORBID_TAGS: ['style', 'iframe', 'object', 'embed', 'form'],
  FORBID_ATTR: ['style'],
};

function renderMarkdown(s) {
  if (!s) return '';
  if (!window.marked || !window.DOMPurify) return escapeHtml(s);
  try {
    const html = window.marked.parse(s);
    return window.DOMPurify.sanitize(html, __MD_PURIFY_CFG);
  } catch (e) {
    return escapeHtml(s);
  }
}

// The TOTP enrollment QR comes back from Supabase as an SVG/markup string that
// we inject via dangerouslySetInnerHTML. Run it through DOMPurify's SVG profile
// first so a hypothetically-tampered response can't smuggle in script.
function sanitizeSVG(markup) {
  if (!markup) return '';
  if (!window.DOMPurify) return '';
  return window.DOMPurify.sanitize(String(markup), {
    USE_PROFILES: { svg: true, svgFilters: true },
  });
}

function escapeHtml(s) {
  return String(s).replace(/[&<>"']/g, c => (
    { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
  ));
}

function stripMarkdown(s) {
  if (!s) return '';
  return String(s)
    .replace(/^#+\s+/gm, '')
    .replace(/\*\*(.+?)\*\*/g, '$1')
    .replace(/\*(.+?)\*/g, '$1')
    .replace(/__(.+?)__/g, '$1')
    .replace(/_(.+?)_/g, '$1')
    .replace(/~~(.+?)~~/g, '$1')
    .replace(/`([^`]+?)`/g, '$1')
    .replace(/!\[(.*?)\]\([^)]*\)/g, '$1')
    .replace(/\[(.+?)\]\([^)]*\)/g, '$1')
    .replace(/^[-*+]\s+/gm, '')
    .replace(/^\d+\.\s+/gm, '')
    .replace(/^>\s+/gm, '')
    .replace(/\n+/g, ' ')
    .trim();
}

// ─── row mappers ────────────────────────────────────────────────────────────
function mapAttachment(a) {
  // `url` is resolved on demand via getSignedFileUrl(storage_path) because the
  // storage bucket is private and links expire.
  return {
    id: a.id,
    name: a.file_name,
    kind: kindOf(a.file_type),
    size: humanSize(a.file_size),
    storage_path: a.storage_path,
    file_type: a.file_type || '',
    note_id: a.note_id,
  };
}

function mapNote(n, comments, attachments) {
  return {
    id: n.id,
    title: n.title || '',
    notebook: n.notebook || 'inbox',
    tags: n.tags || [],
    starred: !!n.starred,
    updated: n.updated_at,
    summary: n.body || '',
    entries: (comments || []).map(mapComment),
    attachments: (attachments || []).map(mapAttachment),
  };
}

function mapComment(c) {
  return { id: c.id, at: c.created_at, body: c.body, tag: c.tag || null, note_id: c.note_id };
}

// ─── fetchers ───────────────────────────────────────────────────────────────
async function fetchAll() {
  const [nbRes, tagsRes, notesRes, commentsRes, attsRes] = await Promise.all([
    __sb.from('notebooks').select('*').order('created_at', { ascending: true }),
    __sb.from('tags').select('*').order('created_at', { ascending: true }),
    __sb.from('notes').select('*').order('updated_at', { ascending: false }),
    __sb.from('comments').select('*').order('created_at', { ascending: true }),
    __sb.from('attachments').select('*').order('created_at', { ascending: false }),
  ]);
  for (const r of [nbRes, tagsRes, notesRes, commentsRes, attsRes]) {
    if (r.error) throw r.error;
  }
  const commentsByNote = {};
  for (const c of commentsRes.data || []) (commentsByNote[c.note_id] ||= []).push(c);
  const attsByNote = {};
  for (const a of attsRes.data || []) (attsByNote[a.note_id] ||= []).push(a);
  return {
    notebooks: nbRes.data || [],
    tags: tagsRes.data || [],
    notes: (notesRes.data || []).map(n => mapNote(n, commentsByNote[n.id], attsByNote[n.id])),
  };
}

// ─── notebook CRUD ──────────────────────────────────────────────────────────
async function createNotebook(name, color, existingNotebooks = []) {
  const id = slugify(name);
  const c = color || pickColor(existingNotebooks.map(n => n.color));
  const { data, error } = await __sb
    .from('notebooks')
    .insert({ id, name, color: c })
    .select()
    .single();
  if (error) throw error;
  return data;
}

async function renameNotebook(id, name) {
  const { data, error } = await __sb
    .from('notebooks').update({ name }).eq('id', id).select().single();
  if (error) throw error;
  return data;
}

async function deleteNotebookById(id) {
  if (id === 'inbox') throw new Error('Inbox cannot be deleted');
  // Reassign notes to inbox first.
  await __sb.from('notes').update({ notebook: 'inbox' }).eq('notebook', id);
  const { error } = await __sb.from('notebooks').delete().eq('id', id);
  if (error) throw error;
}

// ─── tag CRUD ───────────────────────────────────────────────────────────────
async function createTag(name) {
  const id = slugify(name);
  const { data, error } = await __sb
    .from('tags').insert({ id, name }).select().single();
  if (error) throw error;
  return data;
}

async function renameTag(id, name) {
  const { data, error } = await __sb
    .from('tags').update({ name }).eq('id', id).select().single();
  if (error) throw error;
  return data;
}

async function deleteTagById(id) {
  // Strip the tag from every note that has it (Supabase has no atomic array_remove
  // for client requests, so fetch the affected rows and update each).
  const { data: notes } = await __sb.from('notes').select('id, tags').contains('tags', [id]);
  if (notes && notes.length) {
    await Promise.all(notes.map(n => __sb
      .from('notes')
      .update({ tags: (n.tags || []).filter(t => t !== id) })
      .eq('id', n.id)));
  }
  const { error } = await __sb.from('tags').delete().eq('id', id);
  if (error) throw error;
}

// ─── note CRUD ──────────────────────────────────────────────────────────────
async function createNote({ notebook = 'inbox', title = '' } = {}) {
  const { data, error } = await __sb
    .from('notes')
    .insert({ title, body: '', notebook, tags: [], starred: false })
    .select()
    .single();
  if (error) throw error;
  return mapNote(data, [], []);
}

async function updateNote(id, patch) {
  const dbPatch = { ...patch, updated_at: new Date().toISOString() };
  if ('summary' in dbPatch) { dbPatch.body = dbPatch.summary; delete dbPatch.summary; }
  if ('updated' in dbPatch) { dbPatch.updated_at = dbPatch.updated; delete dbPatch.updated; }
  const { data, error } = await __sb
    .from('notes').update(dbPatch).eq('id', id).select().single();
  if (error) throw error;
  return data;
}

async function toggleStar(id, val) {
  return updateNote(id, { starred: val });
}

async function deleteNoteFull(id) {
  const { data: atts } = await __sb.from('attachments').select('storage_path').eq('note_id', id);
  if (atts && atts.length) {
    await __sb.storage
      .from(window.NQ_CONFIG.STORAGE_BUCKET)
      .remove(atts.map(a => a.storage_path));
  }
  const { error } = await __sb.from('notes').delete().eq('id', id);
  if (error) throw error;
}

async function duplicateNote(note) {
  const created = await createNote({
    notebook: note.notebook,
    title: note.title ? note.title + ' (copy)' : '',
  });
  await updateNote(created.id, {
    body: note.summary || '',
    tags: note.tags || [],
    starred: note.starred,
  });
  // Clone entries.
  for (const e of note.entries) {
    await __sb.from('comments').insert({
      note_id: created.id, body: e.body, tag: e.tag || null,
    });
  }
  return created.id;
}

// ─── entry CRUD ─────────────────────────────────────────────────────────────
async function addEntry(noteId, body, tag) {
  const { data, error } = await __sb
    .from('comments')
    .insert({ note_id: noteId, body, tag: tag || null })
    .select()
    .single();
  if (error) throw error;
  await updateNote(noteId, { updated_at: data.created_at });
  return mapComment(data);
}

async function updateEntry(commentId, patch) {
  const { data, error } = await __sb
    .from('comments').update(patch).eq('id', commentId).select().single();
  if (error) throw error;
  return mapComment(data);
}

async function removeEntry(commentId) {
  const { error } = await __sb.from('comments').delete().eq('id', commentId);
  if (error) throw error;
}

// ─── attachment CRUD ────────────────────────────────────────────────────────
async function uploadAttachment(noteId, file) {
  const u = await getCurrentUser();
  if (!u) throw new Error('Not signed in');
  const safe = file.name.replace(/[^a-zA-Z0-9._-]/g, '_');
  // Storage policies require the first path segment to equal auth.uid().
  const path = `${u.id}/${noteId}/${Date.now()}_${safe}`;
  const { error: upErr } = await __sb.storage
    .from(window.NQ_CONFIG.STORAGE_BUCKET)
    .upload(path, file, {
      contentType: file.type || 'application/octet-stream', upsert: false,
    });
  if (upErr) throw upErr;
  const { data, error } = await __sb
    .from('attachments')
    .insert({
      note_id: noteId, storage_path: path, file_name: file.name,
      file_type: file.type || '', file_size: file.size,
    })
    .select()
    .single();
  if (error) throw error;
  await updateNote(noteId, { updated_at: new Date().toISOString() });
  return mapAttachment(data);
}

async function removeAttachment(att) {
  await __sb.storage
    .from(window.NQ_CONFIG.STORAGE_BUCKET)
    .remove([att.storage_path]);
  const { error } = await __sb.from('attachments').delete().eq('id', att.id);
  if (error) throw error;
}

// ─── Evernote ENEX import ───────────────────────────────────────────────────
// Parses an Evernote .enex file, converts each note's HTML body to markdown,
// uploads embedded resources as attachments, and dumps everything into a
// notebook named after the ENEX file.

function parseENEXDate(s) {
  if (!s) return null;
  // Evernote uses YYYYMMDDTHHMMSSZ
  const m = s.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?$/);
  if (m) return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`;
  const d = new Date(s);
  return isNaN(d) ? null : d.toISOString();
}

// Common HTML named entities used in Evernote bodies that XML doesn't define
// natively. We map them to their literal Unicode values so the XML parser
// doesn't blow up on `&nbsp;` etc.
const __HTML_ENTITIES = {
  nbsp:' ', copy:'©', reg:'®', trade:'™',
  hellip:'…', mdash:'—', ndash:'–',
  lsquo:'‘', rsquo:'’', ldquo:'“', rdquo:'”',
  laquo:'«', raquo:'»', deg:'°', middot:'·',
  bull:'•', euro:'€', pound:'£', yen:'¥',
  cent:'¢', sect:'§', para:'¶', plusmn:'±',
  times:'×', divide:'÷', frac12:'½', frac14:'¼',
  frac34:'¾', acute:'´', uml:'¨', cedil:'¸',
  iquest:'¿', iexcl:'¡', sup1:'¹', sup2:'²', sup3:'³',
};

function patchEntities(s) {
  return s.replace(/&([a-zA-Z][a-zA-Z0-9]+);/g, (m, name) => {
    if (['amp','lt','gt','apos','quot'].includes(name)) return m;
    if (__HTML_ENTITIES[name]) return __HTML_ENTITIES[name];
    // Unknown named entity — escape the ampersand so the parser doesn't choke.
    return '&amp;' + name + ';';
  });
}

function directChildren(parent, tagName) {
  if (!parent || !parent.children) return [];
  const lower = tagName.toLowerCase();
  const out = [];
  for (const c of parent.children) {
    if (c.tagName && c.tagName.toLowerCase() === lower) out.push(c);
  }
  return out;
}

function directChildText(parent, tagName) {
  const c = directChildren(parent, tagName)[0];
  return c ? (c.textContent || '') : '';
}

function extractENEXNotes(doc) {
  const out = [];
  doc.querySelectorAll('note').forEach(n => {
    const title = directChildText(n, 'title');
    const contentRaw = directChildText(n, 'content');
    const created = parseENEXDate(directChildText(n, 'created'));
    const updated = parseENEXDate(directChildText(n, 'updated'));
    const tags = directChildren(n, 'tag')
      .map(t => (t.textContent || '').trim()).filter(Boolean);
    const resources = directChildren(n, 'resource').map(r => {
      const data = directChildText(r, 'data').replace(/\s+/g, '');
      const mime = directChildText(r, 'mime') || 'application/octet-stream';
      const ra = directChildren(r, 'resource-attributes')[0];
      const fileName = (ra && directChildText(ra, 'file-name'))
        || directChildText(r, 'file-name')
        || 'attachment';
      return { data, mime, fileName };
    });
    out.push({ title, contentRaw, created, updated, tags, resources });
  });
  return out;
}

function parseENEX(rawText) {
  let text = rawText || '';
  // Strip BOM
  if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
  text = text.trimStart();

  // ENEX references an external DTD (evernote-export4.dtd) which the browser's
  // DOMParser won't fetch, breaking entity resolution. Strip the DOCTYPE and
  // pre-substitute the HTML-style entities Evernote bodies tend to use.
  const noDoctype = text.replace(/<!DOCTYPE[^>]*>/g, '');
  const patched   = patchEntities(noDoctype);

  const attempts = [
    ['xml (cleaned)',  patched,    'application/xml'],
    ['xml (raw)',      text,       'application/xml'],
    ['text/xml',       patched,    'text/xml'],
  ];

  const errs = [];
  for (const [label, src, mime] of attempts) {
    try {
      const doc = new DOMParser().parseFromString(src, mime);
      const pErr = doc.querySelector('parsererror');
      if (pErr) {
        errs.push(`${label}: ${(pErr.textContent || '').replace(/\s+/g, ' ').slice(0, 220)}`);
        continue;
      }
      const notes = extractENEXNotes(doc);
      if (notes.length === 0) {
        errs.push(`${label}: parsed but found 0 notes`);
        continue;
      }
      return notes;
    } catch (e) {
      errs.push(`${label}: ${e.message}`);
    }
  }
  throw new Error('Invalid ENEX file. Parser tried multiple strategies:\n' + errs.join('\n'));
}

function base64ToBlob(b64, mime) {
  const binary = atob(b64);
  const len = binary.length;
  const bytes = new Uint8Array(len);
  for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
  return new Blob([bytes], { type: mime });
}

function htmlBodyToMarkdown(contentRaw) {
  if (!window.TurndownService) return contentRaw;
  const td = new window.TurndownService({
    headingStyle: 'atx',
    codeBlockStyle: 'fenced',
    bulletListMarker: '-',
    emDelimiter: '*',
  });
  // Strip Evernote-specific wrappers; keep their inner content.
  td.addRule('enNote',  { filter: 'en-note',  replacement: (c) => c });
  td.addRule('enMedia', { filter: 'en-media', replacement: () => '' });
  td.addRule('enTodo',  {
    filter: 'en-todo',
    replacement: (c, node) =>
      (node.getAttribute && node.getAttribute('checked') === 'true' ? '- [x] ' : '- [ ] '),
  });
  // Strip <style>/<script>/<meta> (just in case).
  td.remove(['style', 'script', 'meta']);
  try {
    return td.turndown(contentRaw).trim();
  } catch (e) {
    console.warn('Turndown failed, falling back to text', e);
    // Parse in an inert document instead of assigning innerHTML on a live node:
    // DOMParser does not load resources or run inline handlers, so a malicious
    // ENEX body (e.g. <img onerror=…>) can't fire while we extract plain text.
    const inert = new DOMParser().parseFromString(contentRaw, 'text/html');
    return (inert.body.textContent || '').trim();
  }
}

async function importENEX(file, onProgress) {
  const text = await file.text();
  const parsed = parseENEX(text);
  if (parsed.length === 0) throw new Error('No <note> elements found in this ENEX file.');

  // Derive a notebook from the file name (strip extension).
  const nbName = (file.name.replace(/\.enex$/i, '').trim()) || 'Imported';
  const nbId   = slugify(nbName);
  await __sb.from('notebooks').upsert(
    { id: nbId, name: nbName, color: NOTEBOOK_PALETTE[Math.floor(Math.random() * NOTEBOOK_PALETTE.length)] },
    { onConflict: 'id,user_id', ignoreDuplicates: true },
  );

  let imported = 0;
  let errors = 0;
  for (let i = 0; i < parsed.length; i++) {
    const n = parsed[i];
    onProgress?.({ current: i + 1, total: parsed.length, title: n.title || 'Untitled', phase: 'note' });

    try {
      // Ensure each tag exists in the tags table.
      const tagIds = [];
      for (const tagName of n.tags) {
        const id = slugify(tagName);
        if (!id) continue;
        tagIds.push(id);
        try {
          await __sb.from('tags').upsert(
            { id, name: tagName },
            { onConflict: 'id,user_id', ignoreDuplicates: true },
          );
        } catch (err) { /* race-tolerant */ }
      }

      const body = htmlBodyToMarkdown(n.contentRaw);
      const nowIso = new Date().toISOString();
      const { data: noteRow, error: noteErr } = await __sb
        .from('notes')
        .insert({
          title: n.title || '',
          body,
          notebook: nbId,
          tags: tagIds,
          starred: false,
          created_at: n.created || nowIso,
          updated_at: n.updated || n.created || nowIso,
        })
        .select()
        .single();
      if (noteErr) throw noteErr;

      // Upload resources as attachments.
      for (let r = 0; r < n.resources.length; r++) {
        const res = n.resources[r];
        if (!res.data) continue;
        onProgress?.({
          current: i + 1, total: parsed.length,
          title: n.title || 'Untitled',
          phase: 'attachment',
          attachmentIndex: r + 1, attachmentCount: n.resources.length,
          attachmentName: res.fileName,
        });
        try {
          const blob = base64ToBlob(res.data, res.mime);
          const f = new File([blob], res.fileName || 'attachment', { type: res.mime });
          await uploadAttachment(noteRow.id, f);
        } catch (err) {
          console.error('Resource upload failed:', res.fileName, err);
          errors++;
        }
      }
      imported++;
    } catch (err) {
      console.error('Note import failed:', n.title, err);
      errors++;
    }
  }

  return { imported, errors, total: parsed.length, notebookId: nbId, notebookName: nbName };
}

// ─── realtime ───────────────────────────────────────────────────────────────
// Subscribes to inserts/updates/deletes on the five tables and dispatches to
// per-table handlers. Returns an unsubscribe function.
function subscribeRealtime(handlers) {
  const channel = __sb
    .channel('nq-' + Math.random().toString(36).slice(2, 8))
    .on('postgres_changes', { event: '*', schema: 'public', table: 'notebooks'  }, p => handlers.notebook?.(p))
    .on('postgres_changes', { event: '*', schema: 'public', table: 'tags'        }, p => handlers.tag?.(p))
    .on('postgres_changes', { event: '*', schema: 'public', table: 'notes'       }, p => handlers.note?.(p))
    .on('postgres_changes', { event: '*', schema: 'public', table: 'comments'    }, p => handlers.comment?.(p))
    .on('postgres_changes', { event: '*', schema: 'public', table: 'attachments' }, p => handlers.attachment?.(p))
    .subscribe();
  return () => { __sb.removeChannel(channel); };
}

Object.assign(window, {
  // constants
  DEFAULT_NOTEBOOKS, DEFAULT_TAGS, NOTEBOOK_PALETTE,
  // helpers
  kindOf, humanSize, getSignedFileUrl, slugify, pickColor,
  renderMarkdown, stripMarkdown, escapeHtml, sanitizeSVG,
  mapNote, mapAttachment, mapComment,
  // auth
  getCurrentUser, getAALStatus, listMFAFactors,
  signInWithPassword, signUpWithPassword, signOut,
  enrollTOTP, verifyTOTPEnrollment, challengeAndVerifyTOTP,
  onAuthStateChange, seedDefaultsForUser,
  startIdleSignout, validatePasswordStrength,
  // CRUD
  fetchAll,
  createNotebook, renameNotebook, deleteNotebookById,
  createTag, renameTag, deleteTagById,
  createNote, updateNote, toggleStar, deleteNoteFull, duplicateNote,
  addEntry, updateEntry, removeEntry,
  uploadAttachment, removeAttachment,
  // realtime
  subscribeRealtime,
  // import
  parseENEX, importENEX, base64ToBlob, htmlBodyToMarkdown,
});
