// data.jsx — load/persist workshop data
//
// Two modes, picked automatically at startup:
//  - server: page was served by server.js. The server holds the shared board; every local
//            change is diffed into per-entity ops and POSTed, and changes from other people
//            stream in over SSE. localStorage is only a cache.
//  - local:  page opened from disk (or server unreachable). Loads workshop_data.json on first
//            run, then localStorage thereafter — the original single-user behaviour.

const STORAGE_KEY = 'workshop_v2_data';
const COLLECTIONS = ['projects', 'tasks', 'people', 'hands'];
const CLIENT_ID = 'c_' + Math.random().toString(36).slice(2, 12);

function migrate(loaded) {
  return {
    ...loaded,
    projects: (loaded.projects || []).map(p => ({ members: [], private: false, ...p })),
    people: loaded.people || [],
    hands: loaded.hands || [],       // raised hands: { id (= personId), personId, text, askIds, raisedAt }
    tasks: (loaded.tasks || []).map(t => ({
      archived: false,
      archivedByProject: false,
      assignees: [],
      createdBy: null,
      blockers: [],                  // free-text blockers: { id, text, resolved, createdAt, createdBy }
      ...t,
    })),
  };
}

// Per-entity diff between two data snapshots → list of upsert/delete ops.
function diffData(prev, next) {
  const ops = [];
  COLLECTIONS.forEach(coll => {
    const before = new Map((prev?.[coll] || []).map(x => [x.id, x]));
    const after = next?.[coll] || [];
    const seen = new Set();
    after.forEach(item => {
      seen.add(item.id);
      const old = before.get(item.id);
      if (old !== item && JSON.stringify(old) !== JSON.stringify(item)) ops.push({ kind: 'upsert', coll, item });
    });
    before.forEach((_, id) => { if (!seen.has(id)) ops.push({ kind: 'delete', coll, id }); });
  });
  return ops;
}

function applyOpsTo(data, ops) {
  const next = { ...data };
  ops.forEach(op => {
    const list = next[op.coll] ? [...next[op.coll]] : [];
    const i = list.findIndex(x => x.id === (op.kind === 'upsert' ? op.item.id : op.id));
    if (op.kind === 'upsert') { if (i === -1) list.push(op.item); else list[i] = op.item; }
    else if (i !== -1) list.splice(i, 1);
    next[op.coll] = list;
  });
  return next;
}

// tokenRef: ref holding the current session token (sent with every edit).
// onRejected(error): called when the server refuses an edit (e.g. 'sign_in_required').
const useWorkshopData = ({ meId, display, tokenRef, onRejected } = {}) => {
  const [data, setDataState] = React.useState(null);
  const [loading, setLoading] = React.useState(true);
  // connection: 'local' | 'connecting' | 'live' | 'offline'
  const [connection, setConnection] = React.useState('connecting');
  const [presence, setPresence] = React.useState({ online: [], screens: 0, total: 0 });
  // server auth status: which people have a PIN, and whether we're still in first-run setup
  const [authInfo, setAuthInfo] = React.useState({ hasPin: [], setup: false });
  const onRejectedRef = React.useRef(onRejected);
  onRejectedRef.current = onRejected;
  const dataRef = React.useRef(null);
  const modeRef = React.useRef(null);        // 'server' | 'local'
  const versionRef = React.useRef(0);
  const epochRef = React.useRef(null);
  const seqRef = React.useRef(0);
  const pendingRef = React.useRef([]);       // [{ seq, ops, sent, ack }] not yet seen in a snapshot
  const flushingRef = React.useRef(false);
  const retryTimer = React.useRef(null);

  const commit = (next) => {
    dataRef.current = next;
    setDataState(next);
    try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch (e) {}
  };

  // Send pending op batches in order. Server dedupes by seq, so retries are safe.
  const flush = React.useCallback(async () => {
    if (flushingRef.current || modeRef.current !== 'server') return;
    flushingRef.current = true;
    try {
      let batch;
      while ((batch = pendingRef.current.find(b => !b.sent))) {
        const headers = { 'Content-Type': 'application/json' };
        const token = tokenRef && tokenRef.current;
        if (token) headers.Authorization = 'Bearer ' + token;
        const r = await fetch('api/ops', {
          method: 'POST', headers,
          body: JSON.stringify({ clientId: CLIENT_ID, seq: batch.seq, ops: batch.ops }),
        });
        if (r.status === 401 || r.status === 403 || r.status === 409) {
          // refused (not signed in / not allowed): drop the edit and snap back to server state
          const body = await r.json().catch(() => ({}));
          pendingRef.current = pendingRef.current.filter(b => b !== batch);
          onRejectedRef.current && onRejectedRef.current(body.error || 'refused');
          const snap = await fetch('api/data', { cache: 'no-store' }).then(x => x.json());
          receiveSnapshot(snap);
          continue;
        }
        if (!r.ok) throw new Error('HTTP ' + r.status);
        const ack = await r.json();
        batch.sent = true;
        batch.ack = ack; // any later snapshot from this epoch with version >= ack.version includes it
      }
    } catch (e) {
      setConnection('offline');
      clearTimeout(retryTimer.current);
      retryTimer.current = setTimeout(() => { flushingRef.current = false; flush(); }, 3000);
      return;
    }
    flushingRef.current = false;
  }, []);

  // A server snapshot arrived: adopt it, then replay our still-unconfirmed edits on top.
  const receiveSnapshot = React.useCallback((snap) => {
    if (snap.epoch !== epochRef.current) {
      // server restarted: its version counter and seq memory reset, so start fresh
      epochRef.current = snap.epoch;
      versionRef.current = 0;
      // batches acked by the old process were saved; anything unacked gets re-sent
      pendingRef.current = pendingRef.current.filter(b => !b.ack);
      pendingRef.current.forEach(b => { b.sent = false; });
    }
    if (snap.version < versionRef.current) return;
    versionRef.current = snap.version;
    const appliedSeq = (snap.applied && snap.applied[CLIENT_ID]) || 0;
    pendingRef.current = pendingRef.current.filter(b =>
      b.seq > appliedSeq && !(b.ack && b.ack.epoch === snap.epoch && snap.version >= b.ack.version));
    let next = migrate(snap.data);
    pendingRef.current.forEach(b => { next = applyOpsTo(next, b.ops); });
    commit(next);
    if (snap.auth) setAuthInfo(snap.auth);
    setLoading(false);
  }, []);

  React.useEffect(() => {
    let cancelled = false;

    const startLocal = () => {
      modeRef.current = 'local';
      setConnection('local');
      const cached = localStorage.getItem(STORAGE_KEY);
      if (cached) {
        try { commit(migrate(JSON.parse(cached))); setLoading(false); return; } catch (e) {}
      }
      fetch('workshop_data.json')
        .then(r => r.json())
        .then(d => { if (!cancelled) { commit(migrate(d)); setLoading(false); } })
        .catch(() => { if (!cancelled) { commit(migrate({})); setLoading(false); } });
    };

    fetch('api/data', { cache: 'no-store' })
      .then(r => { if (!r.ok) throw new Error(); return r.json(); })
      .then(snap => {
        if (cancelled) return;
        modeRef.current = 'server';
        receiveSnapshot(snap);
        setConnection('live');
      })
      .catch(() => { if (!cancelled) startLocal(); });

    // local mode: follow edits made in other tabs of this browser (e.g. a display tab)
    const onStorage = (e) => {
      if (modeRef.current !== 'local' || e.key !== STORAGE_KEY || !e.newValue) return;
      try { const d = migrate(JSON.parse(e.newValue)); dataRef.current = d; setDataState(d); } catch (err) {}
    };
    window.addEventListener('storage', onStorage);
    return () => { cancelled = true; window.removeEventListener('storage', onStorage); };
  }, []);

  // SSE stream — (re)opened when identity changes so presence stays accurate.
  React.useEffect(() => {
    if (loading || modeRef.current !== 'server') return;
    const qs = new URLSearchParams({ clientId: CLIENT_ID });
    if (meId) qs.set('me', meId);
    if (display) qs.set('display', '1');
    const es = new EventSource('api/events?' + qs);
    es.addEventListener('data', (e) => {
      receiveSnapshot(JSON.parse(e.data));
      setConnection('live');
      if (pendingRef.current.some(b => !b.sent)) flush();
    });
    es.addEventListener('presence', (e) => setPresence(JSON.parse(e.data)));
    es.onerror = () => setConnection('offline');
    return () => es.close();
  }, [loading, meId, display]);

  // Accepts a value or an updater fn (the undo toasts use the fn form).
  const setData = React.useCallback((valueOrFn) => {
    const prev = dataRef.current;
    const next = typeof valueOrFn === 'function' ? valueOrFn(prev) : valueOrFn;
    if (!next || next === prev) return;
    commit(next);
    if (modeRef.current === 'server') {
      const ops = diffData(prev, next);
      if (ops.length === 0) return;
      pendingRef.current.push({ seq: ++seqRef.current, ops });
      flush();
    }
  }, [flush]);

  return { data, loading, setData, connection, presence, authInfo };
};

/* ---------- projects: members, private, same-name disambiguation ---------- */

// Project name, plus its members' initials when another visible project has the same name
// (e.g. two individual "pump test" projects → "pump test · AP" and "pump test · MK").
function projectLabel(project, data) {
  if (!project) return '—';
  const key = (project.name || '').trim().toLowerCase();
  const clash = data.projects.some(p => p.id !== project.id && (p.name || '').trim().toLowerCase() === key);
  if (!clash) return project.name;
  const memberIds = new Set(project.members || []);
  if (project.ownerId) memberIds.add(project.ownerId);
  const owners = Array.from(memberIds).map(id => personById(data, id)).filter(Boolean);
  const tag = owners.length ? owners.map(p => personInitials(p.name)).join('+') : 'shared';
  return `${project.name} · ${tag}`;
}

const projectMembers = (project, data) => {
  if (!project) return [];
  const ids = new Set(project.members || []);
  if (project.ownerId) ids.add(project.ownerId);
  return Array.from(ids).map(id => personById(data, id)).filter(Boolean);
};

/* ---------- raised hands ---------- */

// People a hand is asking for help from (still on the roster). Empty = anyone.
const handAsks = (hand, data) =>
  (hand.askIds || []).map(id => personById(data, id)).filter(Boolean);

/* ---------- people ---------- */

const PERSON_PALETTE = [
  '#C8553D', '#5B7A99', '#4A8062', '#8E5A8C', '#C68A2E', '#2E5E4E', '#9B6B47', '#7A8B3A', '#B84A6A', '#4F6D8A',
];

const personById = (data, id) => (data.people || []).find(p => p.id === id);

const personInitials = (name) => {
  const parts = (name || '?').trim().split(/\s+/).filter(Boolean);
  if (parts.length === 0) return '?';
  if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
  return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
};

const taskAssignees = (task, data) =>
  (task.assignees || []).map(id => personById(data, id)).filter(Boolean);

// personFilter: 'all' | 'me' | 'unassigned' | <personId>
const matchesPersonFilter = (task, personFilter, meId) => {
  if (!personFilter || personFilter === 'all') return true;
  const a = task.assignees || [];
  if (personFilter === 'unassigned') return a.length === 0;
  if (personFilter === 'me') return !!meId && a.includes(meId);
  return a.includes(personFilter);
};

// Remove a person and strip them from every task's assignees.
function removePerson(data, personId) {
  return {
    ...data,
    people: (data.people || []).filter(p => p.id !== personId),
    hands: (data.hands || [])
      .filter(h => h.personId !== personId)
      .map(h => (h.askIds || []).includes(personId) ? { ...h, askIds: h.askIds.filter(id => id !== personId) } : h),
    tasks: data.tasks.map(t => (t.assignees || []).includes(personId)
      ? { ...t, assignees: t.assignees.filter(id => id !== personId) }
      : t),
  };
}

// helpers
const fmtDate = (iso) => {
  if (!iso) return '—';
  const d = new Date(iso);
  return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
};
const daysUntil = (iso) => {
  if (!iso) return null;
  const d = new Date(iso);
  const now = new Date(); now.setHours(0,0,0,0);
  return Math.round((d - now) / 86400000);
};
const isOverdue = (task) => {
  if (!task.dueDate || task.status === 'done') return false;
  return daysUntil(task.dueDate) < 0;
};
const projectById = (data, id) => data.projects.find(p => p.id === id);

const todayStr = () => {
  const d = new Date();
  return d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
};

// A task is "blocked" when any blocking task isn't done yet, or any text blocker is unresolved.
const isTaskBlocked = (task, allTasks) => {
  if (!task) return false;
  if ((task.blockers || []).some(b => !b.resolved)) return true;
  if (!task.blockedBy || task.blockedBy.length === 0) return false;
  return task.blockedBy.some(id => {
    const b = allTasks.find(t => t.id === id);
    return b && b.status !== 'done';
  });
};

const getTaskBlockers = (task, allTasks) =>
  (task.blockedBy || [])
    .map(id => allTasks.find(t => t.id === id))
    .filter(Boolean);

// Human-readable list of what's still holding a task up (task titles + text reasons).
const openBlockerLabels = (task, allTasks) => [
  ...getTaskBlockers(task, allTasks).filter(b => b.status !== 'done').map(b => b.title),
  ...(task.blockers || []).filter(b => !b.resolved).map(b => b.text),
];

// Returns true if adding `candidateBlockerId` as a blocker of `taskId`
// would create a circular dependency (i.e. taskId already appears in
// candidate's transitive blockedBy chain).
const wouldCreateCycle = (taskId, candidateBlockerId, allTasks) => {
  const visited = new Set();
  const visit = (id) => {
    if (id === taskId) return true;
    if (visited.has(id)) return false;
    visited.add(id);
    const t = allTasks.find(x => x.id === id);
    if (!t || !t.blockedBy) return false;
    return t.blockedBy.some(visit);
  };
  return visit(candidateBlockerId);
};

// Deterministic tag color from a curated paper-friendly palette.
const TAG_PALETTE = [
  '#5B7A99', // steel blue
  '#8E5A8C', // plum
  '#C68A2E', // amber
  '#4A8062', // forest
  '#B84A3F', // terracotta-red
  '#6B6358', // taupe
  '#7A8B3A', // olive
  '#9B6B47'  // bronze
];
const tagColor = (name) => {
  let h = 0;
  const s = name || '';
  for (let i = 0; i < s.length; i++) h = ((h << 5) - h) + s.charCodeAt(i);
  return TAG_PALETTE[Math.abs(h) % TAG_PALETTE.length];
};

/* ---------- archive helpers ---------- */

function isVisibleTask(t, showArchived) {
  return showArchived || !t.archived;
}

function isVisibleProject(p, showArchived) {
  return showArchived || !p.archived;
}

// Manual task archive/unarchive. value=true archives, value=false unarchives.
// Always resets archivedByProject to false (manual intent overrides cascade provenance).
function setTaskArchived(data, taskId, value) {
  return {
    ...data,
    tasks: data.tasks.map(t => t.id === taskId
      ? { ...t, archived: !!value, archivedByProject: false }
      : t
    ),
  };
}

// Project cascade-archive: marks the project archived and archives every
// task in it that is not already archived, tagging those tasks with
// archivedByProject = true so unarchive can selectively restore them.
function cascadeArchiveProject(data, projectId) {
  return {
    ...data,
    projects: data.projects.map(p => p.id === projectId ? { ...p, archived: true } : p),
    tasks: data.tasks.map(t => {
      if (t.projectId !== projectId) return t;
      if (t.archived) return t;
      return { ...t, archived: true, archivedByProject: true };
    }),
  };
}

// Project cascade-unarchive: unarchives the project, and only unarchives
// tasks that were archived BY this cascade (archivedByProject === true).
// Tasks the user manually archived stay archived.
function cascadeUnarchiveProject(data, projectId) {
  return {
    ...data,
    projects: data.projects.map(p => p.id === projectId ? { ...p, archived: false } : p),
    tasks: data.tasks.map(t => {
      if (t.projectId !== projectId) return t;
      if (!t.archived || !t.archivedByProject) return t;
      return { ...t, archived: false, archivedByProject: false };
    }),
  };
}

Object.assign(window, {
  migrate, useWorkshopData, diffData, CLIENT_ID,
  projectLabel, projectMembers, handAsks,
  PERSON_PALETTE, personById, personInitials, taskAssignees, matchesPersonFilter, removePerson,
  fmtDate, daysUntil, isOverdue, projectById, todayStr,
  isTaskBlocked, getTaskBlockers, openBlockerLabels, wouldCreateCycle,
  tagColor, TAG_PALETTE, STORAGE_KEY,
  isVisibleTask, isVisibleProject, setTaskArchived, cascadeArchiveProject, cascadeUnarchiveProject,
});
