/* App root — owns state, syncs to storage, wires the views. */

const { useState: useStateA, useEffect: useEffectA, useMemo: useMemoA, useCallback: useCallbackA, useRef: useRefA } = React;
const UA = window.mcalUtils;

/* Fetch the real signed-in user list from Supabase auth via the
   `list_megaeth_users` RPC. Returns null if the function isn't set up
   yet or the call fails — callers fall back to the kv-store roster.

   The RPC is SECURITY DEFINER and reads auth.users (see
   supabase-roster.sql) — clients can't query that schema directly. */
async function fetchAuthRoster() {
  const sb = window.mcalSb;
  if (!sb) return { rows: null, status: 'no-supabase' };
  try {
    const { data, error } = await sb.rpc('list_megaeth_users');
    if (error) {
      // 42883 = function doesn't exist; 42501 = permission denied;
      // PGRST202 = function not exposed via PostgREST yet (cache miss).
      const setupNeeded = error.code === '42883' || error.code === 'PGRST202' || /function .*list_megaeth_users.* does not exist/i.test(error.message || '');
      if (setupNeeded) {
        console.warn('list_megaeth_users RPC not available — paste supabase-roster.sql into Supabase.', error.message);
      } else {
        console.warn('list_megaeth_users RPC failed:', error.message);
      }
      return { rows: null, status: setupNeeded ? 'needs-setup' : 'error' };
    }
    const rows = (data || []).map(row => {
      const o = window.mcalUtils.ownerFromEmail(row.email);
      return {
        id: o.id,
        email: o.email,
        name: row.name || o.name,
        color: o.color, // deterministic from email — stable across refreshes
        picture: row.picture || '',
        lastSeenAt: row.last_sign_in_at ? new Date(row.last_sign_in_at).getTime() : 0,
        _source: 'auth',
      };
    });
    return { rows, status: 'ok' };
  } catch (e) {
    console.warn('list_megaeth_users RPC threw:', e);
    return { rows: null, status: 'error' };
  }
}

/* Inline SQL — kept in JS too so we can render it in the Owners settings
   tab with a Copy button. Identical to supabase-roster.sql. */
const ROSTER_SQL = `create or replace function public.list_megaeth_users()
returns table (
  id uuid,
  email text,
  name text,
  picture text,
  last_sign_in_at timestamptz,
  created_at timestamptz
)
language sql
security definer
set search_path = ''
as $$
  select
    u.id,
    lower(u.email) as email,
    coalesce(
      u.raw_user_meta_data ->> 'full_name',
      u.raw_user_meta_data ->> 'name',
      split_part(u.email, '@', 1)
    ) as name,
    coalesce(
      u.raw_user_meta_data ->> 'avatar_url',
      u.raw_user_meta_data ->> 'picture',
      ''
    ) as picture,
    u.last_sign_in_at,
    u.created_at
  from auth.users u
  where u.email ilike '%@megaeth.com'
  order by u.last_sign_in_at desc nulls last;
$$;

revoke all on function public.list_megaeth_users() from public, anon;
grant execute on function public.list_megaeth_users() to authenticated;`;
window.mcalRosterSQL = ROSTER_SQL;
window.mcalSupabaseSqlEditorUrl = (function () {
  const base = window.MCAL_SUPABASE_URL || '';
  const m = base.match(/^https:\/\/([a-z0-9-]+)\.supabase\.co/i);
  return m ? `https://supabase.com/dashboard/project/${m[1]}/sql/new` : '';
})();

/* Build the signed-in user roster.
   When the Supabase RPC works, it's the SOLE source of truth — we drop
   the hardcoded team list and any stale kv entries from previous runs.
   The Owners picker exactly mirrors auth.users.
   When the RPC isn't deployed yet (or fails), fall back to kv ∪ the
   hardcoded team so the picker is still useful for setup. */
function mergeRoster(authRoster, kvRoster) {
  if (authRoster) {
    // Auth-only mode. Mark each record with _source so downstream code
    // (e.g. the derivedOwners memo) can prune anything that didn't come
    // from auth. User-customized colors live in kv (the auth fetch
    // returns the deterministic default) — preserve them on every
    // refresh so a manual recolor isn't undone by the next poll.
    const kvById = new Map((kvRoster || []).map(u => [u.id, u]));
    return authRoster.map(u => {
      const kv = kvById.get(u.id);
      return {
        ...u,
        _source: 'auth',
        color: (kv && kv.color) || u.color,
      };
    });
  }
  // Fallback path: no auth roster available yet.
  const byId = new Map();
  const team = (window.MCAL_DEFAULTS && window.MCAL_DEFAULTS.TEAM_EMAILS) || [];
  team.forEach(email => {
    const o = window.mcalUtils.ownerFromEmail(email);
    if (!o) return;
    byId.set(o.id, {
      id: o.id, email: o.email, name: o.name, color: o.color,
      picture: '', lastSeenAt: 0,
    });
  });
  (kvRoster || []).forEach(u => {
    // Skip stale auth-mode records — when we last persisted from auth,
    // we wrote _source: 'auth'. If RPC failed this run, that stale
    // record is no longer valid; rely on team + freshly-seen entries.
    if (u._source === 'auth') return;
    const prev = byId.get(u.id) || {};
    byId.set(u.id, { ...prev, ...u });
  });
  return Array.from(byId.values());
}

/* Defer unmount of an animated panel by `ms` so its exit keyframe can play.
   Returns { shown, leaving } — `shown` is the most-recent truthy value (or
   null), `leaving` is true while the exit animation is running. */
function useDeferredUnmount(value, ms) {
  const [state, setState] = useStateA({ shown: value || null, leaving: false });
  useEffectA(() => {
    if (value) {
      setState({ shown: value, leaving: false });
      return;
    }
    if (state.shown) {
      setState(s => ({ ...s, leaving: true }));
      const t = setTimeout(() => setState({ shown: null, leaving: false }), ms);
      return () => clearTimeout(t);
    }
  }, [value]);
  return state;
}

function App({ user, signOut }) {
  /* Default to in-memory defaults so the calendar renders immediately —
     no loading screen. The Supabase fetch in the effect below replaces
     these once data arrives. */
  const [events, setEventsRaw] = useStateA(() => window.MCAL_DEFAULTS.EVENTS || []);
  const [options, setOptionsRaw] = useStateA(() => window.MCAL_DEFAULTS.OPTIONS);
  const [signedInUsers, setSignedInUsers] = useStateA([]);
  const [rosterRpcStatus, setRosterRpcStatus] = useStateA('unknown'); // 'ok' | 'needs-setup' | 'error' | 'no-supabase' | 'unknown'
  // GTM Team subset — owner ids who are part of the GTM rollout team.
  // When populated, @mention autocomplete only suggests these owners.
  // When empty, falls back to the full owner roster.
  const [gtmTeam, setGtmTeamState] = useStateA([]);
  const [theme, setTheme] = useStateA(localStorage.getItem('mcal-theme') || 'dark');
  const [view, setView] = useStateA('calendar');  // 'calendar' | 'table' | 'mytasks'
  // Two-week grid is the default — gives each day-cell ~3x more vertical
  // room than the month view without losing weekly cadence context.
  const [mode, setMode] = useStateA('month'); // 'month' | 'fortnight' | 'week' | 'lanes' | 'timeline' | 'kanban'
  const [cursor, setCursor] = useStateA(UA.todayISO());
  const [selectedId, setSelectedId] = useStateA(null);
  const [settingsOpen, setSettingsOpen] = useStateA(false);
  const [settingsTab, setSettingsTab] = useStateA('categories');
  const [colorBy, setColorBy] = useStateA('categories');
  const [laneGroup, setLaneGroup] = useStateA('channels');
  const [timelineGroup, setTimelineGroup] = useStateA('categories');
  const [filters, setFilters] = useStateA(() => {
    const HT = (window.MCAL_DEFAULTS && window.MCAL_DEFAULTS.HOLIDAY_TYPES) || [];
    const defaultOn = new Set(HT.filter(h => h.defaultOn).map(h => h.id));
    return {
      search: '',
      channels: new Set(),
      owners: new Set(),
      categories: new Set(),
      products: new Set(),
      stages: new Set(),
      holidayShow: false,        // master toggle defaults OFF
      holidayTypes: defaultOn,
      conferencesShow: false,    // crypto conferences default OFF
    };
  });
  const [syncedAt, setSyncedAt] = useStateA(UA.nowESTLabel());

  /* "today" is held in state and re-evaluated every minute so the green
     highlight + new-event defaults flip when the EST date rolls over
     while the page is left open. Same tick refreshes the sync label so
     the top-bar clock stays current. */
  const [today, setToday] = useStateA(UA.todayISO());
  useEffectA(() => {
    const tick = () => {
      const t = UA.todayISO();
      setToday(prev => prev === t ? prev : t);
      setSyncedAt(UA.nowESTLabel());
    };
    const id = setInterval(tick, 60 * 1000);
    return () => clearInterval(id);
  }, []);

  // Apply theme
  useEffectA(() => {
    document.documentElement.setAttribute('data-theme', theme);
    localStorage.setItem('mcal-theme', theme);
  }, [theme]);

  // Initial load from store
  useEffectA(() => {
    (async () => {
      const e = await window.mcalStore.get('events');
      const o = await window.mcalStore.get('options');
      const holidaysSeeded = await window.mcalStore.get('holidays_seeded_v1');
      const baseEvents = e || window.MCAL_DEFAULTS.EVENTS;
      let initialEvents = baseEvents;

      // One-time wipe of the original demo events (evt_*). The seed used to
      // include MOSS/Hit One/etc. for visual mockup purposes; now we start
      // with an empty calendar and the team adds their own.
      const samplesWiped = await window.mcalStore.get('samples_wiped_v1');
      if (!samplesWiped) {
        const stripped = baseEvents.filter(x => !(x.id && x.id.startsWith('evt_')));
        if (stripped.length !== baseEvents.length) {
          initialEvents = stripped;
          await window.mcalStore.set('events', initialEvents);
        }
        await window.mcalStore.set('samples_wiped_v1', true);
      }

      // First-run seed: append all 2026 holidays + conferences once. We key
      // off versioned flags so re-running the seed is a no-op until bumped.
      if (!holidaysSeeded) {
        const seedHols = window.MCAL_DEFAULTS.HOLIDAYS || [];
        const have = new Set(initialEvents.map(x => x.id));
        const toAdd = seedHols.filter(h => !have.has(h.id));
        if (toAdd.length) {
          initialEvents = [...initialEvents, ...toAdd];
          await window.mcalStore.set('events', initialEvents);
        }
        await window.mcalStore.set('holidays_seeded_v1', true);
      }
      const confsSeeded = await window.mcalStore.get('conferences_seeded_v1');
      if (!confsSeeded) {
        const seedConf = window.MCAL_DEFAULTS.CONFERENCES || [];
        const have = new Set(initialEvents.map(x => x.id));
        const toAdd = seedConf.filter(c => !have.has(c.id));
        if (toAdd.length) {
          initialEvents = [...initialEvents, ...toAdd];
          await window.mcalStore.set('events', initialEvents);
        }
        await window.mcalStore.set('conferences_seeded_v1', true);
      }

      // Migrate legacy single-owner events to the multi-owner shape.
      // We DO NOT persist this migration eagerly — it happens lazily on
      // first edit so unchanged events keep their existing payload. The
      // runtime always sees `owners` thanks to the normalizer in
      // ensureOwners() below.
      setEventsRaw(initialEvents);
      setOptionsRaw(o || window.MCAL_DEFAULTS.OPTIONS);

      // Authoritative roster comes from Supabase auth.users via the
      // list_megaeth_users() RPC (see supabase-roster.sql). The kv
      // store is a secondary source that the client also writes to on
      // sign-in — used as fallback when the RPC isn't available yet.
      // Final list = RPC users ∪ kv users ∪ hardcoded team, with RPC
      // data winning on conflicts.
      const kvRoster = (await window.mcalStore.get('signed_in_users')) || [];
      const team = await window.mcalStore.get('gtm_team');
      if (Array.isArray(team)) setGtmTeamState(team);
      const { rows: authRoster, status } = await fetchAuthRoster();
      setRosterRpcStatus(status);
      const merged = mergeRoster(authRoster, kvRoster);
      // Persist the merged result so clients without RPC access still
      // see everyone, and so other dashboards in the workspace pick up
      // the same roster via the real-time kv subscription.
      if (JSON.stringify(merged) !== JSON.stringify(kvRoster)) {
        await window.mcalStore.set('signed_in_users', merged);
      }
      setSignedInUsers(merged);
    })();
  }, []);

  /* Re-fetch the auth roster every 5 minutes while the page is open.
     Picks up teammates who sign in for the first time without requiring
     a manual refresh. Throttled at 5min so we don't hammer the RPC. */
  useEffectA(() => {
    const tick = async () => {
      const { rows: fresh, status } = await fetchAuthRoster();
      setRosterRpcStatus(status);
      if (!fresh) return;
      const current = (await window.mcalStore.get('signed_in_users')) || [];
      const merged = mergeRoster(fresh, current);
      if (JSON.stringify(merged) !== JSON.stringify(current)) {
        await window.mcalStore.set('signed_in_users', merged);
        setSignedInUsers(merged);
      }
    };
    const id = setInterval(tick, 5 * 60 * 1000);
    return () => clearInterval(id);
  }, []);

  // When the user signs in (or comes back with a cached session), make
  // sure they're in the local roster AND re-pull the auth roster from
  // Supabase. That second fetch catches teammates who signed in since
  // the page last loaded.
  useEffectA(() => {
    if (!user || !user.email) return;
    (async () => {
      const existing = (await window.mcalStore.get('signed_in_users')) || [];
      const { rows: authRoster, status } = await fetchAuthRoster();
      setRosterRpcStatus(status);
      const merged = mergeRoster(authRoster, existing);
      // Guarantee the current user is present even if the RPC isn't
      // wired up yet — they're definitely signed in right now.
      const owner = UA.ownerFromEmail(user.email);
      if (owner && !merged.some(u => u.id === owner.id)) {
        merged.push({
          id: owner.id, email: owner.email, name: owner.name, color: owner.color,
          picture: user.picture || '', lastSeenAt: Date.now(),
        });
      }
      if (JSON.stringify(merged) !== JSON.stringify(existing)) {
        await window.mcalStore.set('signed_in_users', merged);
      }
      setSignedInUsers(merged);
    })();
  }, [user && user.email]);

  // Persist on change (debounced)
  const writeTimer = useRefA(null);
  const persist = useCallbackA((key, val) => {
    if (writeTimer.current) clearTimeout(writeTimer.current);
    writeTimer.current = setTimeout(async () => {
      await window.mcalStore.set(key, val);
      setSyncedAt(UA.nowESTLabel());
    }, 250);
  }, []);

  const setEvents = useCallbackA(next => {
    setEventsRaw(next);
    persist('events', next);
  }, [persist]);
  const setOptions = useCallbackA(next => {
    setOptionsRaw(next);
    persist('options', next);
  }, [persist]);

  /* Owner color override — updates the kv-stored signed_in_users
     record. Persists immediately so other clients see the new color
     via realtime subscription. The auth roster RPC doesn't store
     colors (we derive them deterministically from the email), so this
     override stays in the kv layer and `mergeRoster` preserves it on
     subsequent auth fetches. */
  const updateOwner = useCallbackA((id, patch) => {
    setSignedInUsers(prev => {
      const next = prev.map(u => u.id === id ? { ...u, ...patch } : u);
      window.mcalStore.set('signed_in_users', next);
      return next;
    });
  }, []);

  /* GTM Team setter — persists to kv so other clients see the same
     team via the realtime subscription. */
  const setGtmTeam = useCallbackA((next) => {
    const arr = Array.isArray(next) ? next : [];
    setGtmTeamState(arr);
    window.mcalStore.set('gtm_team', arr);
  }, []);

  /* Real-time sync: when ANY remote change lands, refetch the affected
     dataset. We diff against the local ref via JSON.stringify before
     setting state to avoid no-op re-renders (the subscription also fires
     after our own writes — this short-circuits that bounce-back). */
  const eventsRef = useRefA(events);
  const optionsRef = useRefA(options);
  useEffectA(() => { eventsRef.current = events; }, [events]);
  useEffectA(() => { optionsRef.current = options; }, [options]);
  useEffectA(() => {
    if (!window.mcalStore.subscribe) return;
    const off = window.mcalStore.subscribe(async (which) => {
      if (which === 'events') {
        const e = await window.mcalStore.get('events');
        if (e && JSON.stringify(e) !== JSON.stringify(eventsRef.current)) setEventsRaw(e);
      } else {
        const o = await window.mcalStore.get('options');
        if (o && JSON.stringify(o) !== JSON.stringify(optionsRef.current)) setOptionsRaw(o);
        const r = await window.mcalStore.get('signed_in_users');
        if (r) setSignedInUsers(r);
        const t = await window.mcalStore.get('gtm_team');
        if (Array.isArray(t)) setGtmTeamState(t);
      }
      setSyncedAt(UA.nowESTLabel());
    });
    return () => off && off();
  }, []);

  /* The Owners picker.
     • When the Supabase auth RPC is live (rosterRpcStatus === 'ok'),
       this list is *exactly* what's in auth.users. No seed roster, no
       orphan owner ids extracted from old events — anything not in
       auth.users disappears from the picker.
     • Until the RPC is wired up, fall back to whatever the kv store
       has (seed team list + cached entries) plus any orphan ids
       referenced by existing events. That keeps the app usable during
       setup. */
  const derivedOwners = useMemoA(() => {
    // Auth-mode if our own RPC succeeded OR another client has already
    // persisted an auth-sourced roster into kv (so we trust it via the
    // realtime sync). The _source: 'auth' marker is set by mergeRoster
    // whenever the RPC was the data source for an entry.
    const kvFromAuth = (signedInUsers || []).some(u => u._source === 'auth');
    const authMode = rosterRpcStatus === 'ok' || kvFromAuth;
    const byId = new Map();

    (signedInUsers || []).forEach(u => {
      byId.set(u.id, {
        id: u.id, name: u.name, color: u.color,
        email: u.email, picture: u.picture,
        _signedIn: true, _lastSeenAt: u.lastSeenAt || 0,
      });
    });

    if (!authMode) {
      // Pre-RPC fallback: merge in seed roster + orphan event references.
      const seed = (options && options.owners) || [];
      seed.forEach(o => {
        if (!byId.has(o.id)) {
          byId.set(o.id, { id: o.id, name: o.name, color: o.color, _seed: true });
        }
      });
      const synthesizeName = id => id
        .split(/[._-]+/)
        .filter(Boolean)
        .map(p => p.charAt(0).toUpperCase() + p.slice(1))
        .join(' ') || id;
      (events || []).forEach(ev => {
        if (ev.isHoliday || ev.isConference) return;
        const collect = (item) => {
          const ids = UA.ownerIdsOf(item);
          ids.forEach(id => {
            if (!id || byId.has(id)) return;
            const stub = UA.ownerFromEmail(id + '@megaeth.com');
            byId.set(id, { id, name: synthesizeName(id), color: stub.color, _orphan: true });
          });
        };
        collect(ev);
        (ev.subtasks || []).forEach(collect);
      });
    }

    return Array.from(byId.values()).sort((a, b) => {
      if (a._signedIn !== b._signedIn) return a._signedIn ? -1 : 1;
      if (a._signedIn && b._signedIn) return (b._lastSeenAt || 0) - (a._lastSeenAt || 0);
      return (a.name || '').localeCompare(b.name || '');
    });
  }, [signedInUsers, options, events, rosterRpcStatus]);

  const optionsWithOwners = useMemoA(() => {
    return { ...options, owners: derivedOwners, gtmTeam };
  }, [options, derivedOwners, gtmTeam]);

  // The signed-in viewer's owner id — used by My Tasks and to pre-fill
  // new tasks. Falls back to null until the user object hydrates.
  const myOwnerId = useMemoA(() => {
    if (!user || !user.email) return null;
    const o = UA.ownerFromEmail(user.email);
    return o ? o.id : null;
  }, [user && user.email]);

  /* Draft event — a pending event that lives only in component state
     until the user types a title. Views never see it (it's not in
     `events`), so it can be discarded silently if the panel is closed
     without naming. The DetailPanel reads/writes it via `update` below,
     which auto-commits to the events array as soon as the title becomes
     non-empty. */
  const [draftEvent, setDraftEvent] = useStateA(null);

  // CRUD
  const update = useCallbackA((id, patch) => {
    // When marking a parent as done, cascade: all subtasks also flip to
    // done. The reverse (un-done) does NOT un-mark subtasks — a parent
    // can have its individual children move back through stages.
    const cascadeDone = (e) => {
      if (patch.stage !== 'done') return e;
      const subs = (e.subtasks || []).map(s => ({ ...s, stage: 'done' }));
      return { ...e, subtasks: subs };
    };
    // Updates against the draft stay in-memory until the title is set.
    if (draftEvent && id === draftEvent.id) {
      const merged = cascadeDone({ ...draftEvent, ...patch, updatedAt: Date.now() });
      if (merged.title && merged.title.trim() !== '') {
        // Promote draft → real event. Clear draft state in the same
        // batch so React doesn't render both representations.
        setEventsRaw(prev => {
          const next = [...prev, merged];
          persist('events', next);
          return next;
        });
        setDraftEvent(null);
      } else {
        setDraftEvent(merged);
      }
      return;
    }
    setEventsRaw(prev => {
      const next = prev.map(e => e.id === id ? cascadeDone({ ...e, ...patch, updatedAt: Date.now() }) : e);
      persist('events', next);
      return next;
    });
  }, [draftEvent, persist]);
  const remove = useCallbackA(id => {
    // Removing the draft just discards it.
    if (draftEvent && id === draftEvent.id) {
      setDraftEvent(null);
      setSelectedId(s => s === id ? null : s);
      return;
    }
    setEventsRaw(prev => {
      const next = prev.filter(e => e.id !== id);
      persist('events', next);
      return next;
    });
    setSelectedId(s => s === id ? null : s);
  }, [draftEvent, persist]);

  /* Nest a regular event as a sub-task of another event.
     - draggedId: event being dragged
     - targetId: event being dropped onto
     - dropDate: ISO date the user dropped on (for multi-day targets the
       column under the cursor, for single-day targets the target's date)
     Strips event-only fields (end, isRange, tbd, subtasks) and stores the
     remaining fields on the target's subtasks list. The dragged event is
     removed from the top-level events list. */
  const nestAsSubtask = useCallbackA((draggedId, targetId, dropDate) => {
    if (!draggedId || draggedId === targetId) return;
    setEventsRaw(prev => {
      const dragged = prev.find(e => e.id === draggedId);
      const target = prev.find(e => e.id === targetId);
      if (!dragged || !target) return prev;
      // Don't allow nesting a holiday/conference (they're not editable).
      if (dragged.isHoliday || dragged.isConference || target.isHoliday || target.isConference) return prev;
      // Tasks that already have their own sub-tasks can only be re-dated;
      // nesting them would silently discard their sub-task list (the
      // child schema below has no place for nested sub-tasks).
      if (dragged.subtasks && dragged.subtasks.length > 0) return prev;
      const st = {
        id: 'st_' + Math.random().toString(36).slice(2, 7),
        title: dragged.title,
        date: dropDate || target.start,
        stage: dragged.stage || 'ideation',
        channel: dragged.channel,
        owners: UA.ownerIdsOf(dragged),
        category: dragged.category,
        product: dragged.product || '',
        subproduct: dragged.subproduct || '',
        format: dragged.format,
        notes: dragged.notes || '',
        url: dragged.url || '',
        media: dragged.media || [],
      };
      const next = prev
        .filter(e => e.id !== draggedId)
        .map(e => e.id === targetId
          ? { ...e, subtasks: [...(e.subtasks || []), st], updatedAt: Date.now() }
          : e);
      persist('events', next);
      return next;
    });
    setSelectedId(s => s === draggedId ? null : s);
  }, [persist]);

  /* Move a sub-task between parents.
     - fromParentId: current parent
     - subId: sub-task id within fromParent
     - toParentId: new parent (must be different)
     - newDate: ISO date for the sub-task under its new parent
     The sub-task object itself is preserved (id + all field overrides
     intact); only its date and parent change. */
  const moveSubtask = useCallbackA((fromParentId, subId, toParentId, newDate) => {
    if (fromParentId === toParentId) return;
    setEventsRaw(prev => {
      const from = prev.find(e => e.id === fromParentId);
      const to = prev.find(e => e.id === toParentId);
      if (!from || !to) return prev;
      if (to.isHoliday || to.isConference) return prev;
      const st = (from.subtasks || []).find(s => s.id === subId);
      if (!st) return prev;
      const movedSt = { ...st, date: newDate };
      const next = prev.map(e => {
        if (e.id === fromParentId) {
          return { ...e, subtasks: (e.subtasks || []).filter(s => s.id !== subId), updatedAt: Date.now() };
        }
        if (e.id === toParentId) {
          return { ...e, subtasks: [...(e.subtasks || []), movedSt], updatedAt: Date.now() };
        }
        return e;
      });
      persist('events', next);
      return next;
    });
  }, [persist]);

  /* Lift a sub-task back out to a standalone event on a new date.
     - parentId: parent event id
     - subId: sub-task id within the parent
     - newDate: ISO date for the new top-level event (single-day) */
  const liftSubtask = useCallbackA((parentId, subId, newDate) => {
    setEventsRaw(prev => {
      const parent = prev.find(e => e.id === parentId);
      if (!parent) return prev;
      const st = (parent.subtasks || []).find(s => s.id === subId);
      if (!st) return prev;
      const ev = {
        id: UA.uid(),
        title: st.title || '',
        start: newDate,
        end: newDate,
        tbd: false,
        isRange: false,
        channel: st.channel || parent.channel,
        owners: UA.ownerIdsOf(st, UA.ownerIdsOf(parent)),
        category: st.category || parent.category,
        product: st.product || parent.product || '',
        subproduct: st.subproduct || parent.subproduct || '',
        format: st.format || parent.format,
        stage: st.stage || 'ideation',
        notes: st.notes || '',
        url: st.url || '',
        media: st.media || [],
        subtasks: [],
        createdAt: Date.now(),
        updatedAt: Date.now(),
      };
      const next = prev.map(e => e.id === parentId
        ? { ...e, subtasks: (e.subtasks || []).filter(s => s.id !== subId), updatedAt: Date.now() }
        : e);
      next.push(ev);
      persist('events', next);
      return next;
    });
  }, [persist]);
  /* Create a draft event. Opens the detail panel for editing but does
     NOT add the event to `events` yet — it's only committed when the
     user types a non-empty title (see `update`). Closing the panel
     without naming silently discards the draft. */
  const addEvent = useCallbackA(at => {
    const startIso = typeof at === 'string' ? at : (at && at.start) || today;
    const tpl = typeof at === 'object' ? at : {};
    const pick = (key, fallback) => (key in tpl ? tpl[key] : fallback);
    // New tasks default to the signed-in user as their sole owner — the
    // creator is the implicit assignee. They can add or remove owners
    // afterwards via the multi-select in the detail panel.
    const defaultOwners = myOwnerId ? [myOwnerId] : [];
    const ev = {
      id: UA.uid(),
      title: tpl.title || '',
      start: tpl.start || startIso,
      end: tpl.end || tpl.start || startIso,
      tbd: false,
      needsReview: false,
      channel:  pick('channel',  options ? (options.channels[0]?.id  || '') : ''),
      owners:   pick('owners',   defaultOwners),
      category: pick('category', options ? (options.categories[0]?.id || '') : ''),
      product:    pick('product', ''),
      subproduct: pick('subproduct', ''),
      format:   pick('format',   options ? (options.formats[0]?.id   || '') : ''),
      stage: tpl.stage || 'ideation',
      notes: '',
      url: '',
      media: [],
      subtasks: [],
      isRange: false,
      createdAt: Date.now(),
      updatedAt: Date.now(),
    };
    setDraftEvent(ev);
    setSelectedId(ev.id);
    setSettingsOpen(false);
  }, [options, today, myOwnerId]);

  /* Filter pipeline — split by event kind so the heavy work only re-runs
     when relevant filter state actually changed.

     - filters drives popover UI (stays urgent so checkboxes feel instant)
     - dFilters is the React-deferred copy used by the downstream filter +
       view re-render path. React 18 schedules these as low-priority so
       rapid checkbox clicks don't block input.
     - We further split events by kind (regular / holiday / conference) so
       toggling holiday filters does NOT recompute the regular-events
       filter (which is the largest set + the costliest downstream work). */
  const dFilters = React.useDeferredValue(filters);

  const eventsByKind = useMemoA(() => {
    if (!events) return { regular: [], holiday: [], conf: [] };
    const regular = [], holiday = [], conf = [];
    for (const e of events) {
      if (e.isConference) conf.push(e);
      else if (e.isHoliday) holiday.push(e);
      else regular.push(e);
    }
    return { regular, holiday, conf };
  }, [events]);

  const q = dFilters.search.trim().toLowerCase();
  const matchesText = useCallbackA(e => {
    if (!q) return true;
    return (e.title + ' ' + (e.notes || '')).toLowerCase().includes(q);
  }, [q]);

  const filteredRegular = useMemoA(() => {
    return eventsByKind.regular.filter(e => {
      if (dFilters.channels.size && !dFilters.channels.has(e.channel)) return false;
      if (dFilters.owners.size) {
        const ownerIds = UA.ownerIdsOf(e);
        if (!ownerIds.some(o => dFilters.owners.has(o))) return false;
      }
      if (dFilters.categories.size && !dFilters.categories.has(e.category)) return false;
      if (dFilters.products.size && !dFilters.products.has(e.product)) return false;
      if (dFilters.stages.size && !dFilters.stages.has(e.stage)) return false;
      return matchesText(e);
    });
  }, [eventsByKind.regular, dFilters.channels, dFilters.owners, dFilters.categories, dFilters.products, dFilters.stages, matchesText]);

  // My Tasks: every regular event where the viewer is one of the owners,
  // plus every sub-task they own (sub-tasks inherit owners from parent
  // unless they override them). Lifted out as a memo so the My Tasks
  // view doesn't pay the cost on unrelated re-renders.
  const myTasks = useMemoA(() => {
    if (!myOwnerId) return { events: [], subtasks: [] };
    const evs = [];
    const subs = [];
    eventsByKind.regular.forEach(e => {
      const eOwners = UA.ownerIdsOf(e);
      if (eOwners.includes(myOwnerId)) evs.push(e);
      (e.subtasks || []).forEach(st => {
        const stOwners = UA.ownerIdsOf(st, eOwners);
        if (stOwners.includes(myOwnerId)) subs.push({ ev: e, st });
      });
    });
    return { events: evs, subtasks: subs };
  }, [eventsByKind.regular, myOwnerId]);

  const filteredHolidays = useMemoA(() => {
    if (!dFilters.holidayShow) return [];
    return eventsByKind.holiday.filter(e => {
      if (dFilters.holidayTypes && !dFilters.holidayTypes.has(e.holiday_type)) return false;
      return matchesText(e);
    });
  }, [eventsByKind.holiday, dFilters.holidayShow, dFilters.holidayTypes, matchesText]);

  const filteredConferences = useMemoA(() => {
    if (!dFilters.conferencesShow) return [];
    return eventsByKind.conf.filter(matchesText);
  }, [eventsByKind.conf, dFilters.conferencesShow, matchesText]);

  const filtered = useMemoA(
    () => filteredRegular.concat(filteredHolidays).concat(filteredConferences),
    [filteredRegular, filteredHolidays, filteredConferences]
  );

  // Per-mode counts (visible filtered events that intersect the current window)
  const counts = useMemoA(() => {
    if (!filtered) return {};
    const monthS = UA.monthStart(cursor);
    const monthE = UA.monthEnd(cursor);
    const weekS = UA.weekStartSunday(cursor);
    const weekE = UA.addDays(weekS, 6);
    const fortnightE = UA.addDays(weekS, 13);
    return {
      month:     filtered.filter(e => UA.eventOverlaps(e, monthS, monthE)).length,
      fortnight: filtered.filter(e => UA.eventOverlaps(e, weekS, fortnightE)).length,
      week:      filtered.filter(e => UA.eventOverlaps(e, weekS, weekE)).length,
      lanes:     filtered.filter(e => UA.eventOverlaps(e, weekS, weekE)).length,
      timeline:  filtered.filter(e => UA.eventOverlaps(e, weekS, weekE)).length,
      // Kanban is date-agnostic — count all workflow items (excluding
      // holidays/conferences which the kanban view also filters out).
      kanban:    filtered.filter(e => !e.isHoliday && !e.isConference).length,
    };
  }, [filtered, cursor]);

  // O(1) lookup by id, rebuilt only when the events array reference
  // changes. Used to memoize `selectedEvent` below.
  const eventsById = useMemoA(() => {
    const m = new Map();
    (events || []).forEach(e => m.set(e.id, e));
    return m;
  }, [events]);

  // Prefer the draft when its id is the selection — drafts aren't in
  // `events` yet so the Map would miss them.
  const selectedEvent = useMemoA(() => {
    if (draftEvent && selectedId === draftEvent.id) return draftEvent;
    if (selectedId && eventsById.has(selectedId)) return eventsById.get(selectedId);
    return null;
  }, [draftEvent, selectedId, eventsById]);
  // Slide-in/out animation: keep panels mounted while the exit keyframe plays.
  const detailLife = useDeferredUnmount(selectedEvent && !settingsOpen ? selectedEvent : null, 320);
  const settingsLife = useDeferredUnmount(settingsOpen ? true : null, 320);

  // Disable hover cards while the right detail / settings panel is open.
  useEffectA(() => {
    if (window.mcalSetHoverDisabled) {
      window.mcalSetHoverDisabled(!!(selectedEvent || settingsOpen));
    }
  }, [selectedEvent, settingsOpen]);

  // Keyboard
  useEffectA(() => {
    const onKey = e => {
      if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
      if (e.key === 'Escape') { setDraftEvent(null); setSelectedId(null); setSettingsOpen(false); }
      if (e.key === 'n') addEvent(today);
      if (e.key === '/' || (e.key === 'k' && (e.metaKey || e.ctrlKey))) {
        e.preventDefault();
        const s = document.querySelector('.search');
        if (s) s.focus();
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [addEvent, today]);

  /* Stable callbacks so React.memo on the heavy view components can
     actually skip re-renders. Without these, every App render produces
     new inline arrow functions and memo's shallow prop-compare misses. */
  /* openEvent(parentId, optional subtaskId) — picks an event for the
     detail panel. When a sub-task id is also passed, the detail panel
     auto-expands that sub-task's editor row + scrolls to it. */
  const [openedSubtaskId, setOpenedSubtaskId] = useStateA(null);
  const openEvent = useCallbackA((id, subId) => {
    // Switching to a real event discards an in-flight unnamed draft.
    setDraftEvent(null);
    setSelectedId(id);
    setOpenedSubtaskId(subId || null);
  }, []);
  const closeDetail = useCallbackA(() => {
    // Closing while a draft is open silently discards the unnamed event.
    setDraftEvent(null);
    setSelectedId(null);
    setOpenedSubtaskId(null);
  }, []);
  const openSettings = useCallbackA(() => setSettingsOpen(o => !o), []);
  const closeSettings = useCallbackA(() => setSettingsOpen(false), []);
  const addEventAt = useCallbackA(ev => addEvent(ev), [addEvent]);
  const openSettingsTab = useCallbackA(tab => {
    setSettingsTab(tab);
    setSettingsOpen(true);
    setSelectedId(null);
  }, []);


  /* Wheel-driven calendar navigation. The active mode determines how far
     each "click" advances:
       month       → ±1 month
       fortnight   → ±14 days
       week/lanes  → ±7 days
       timeline    → ±7 days
     Kanban + Table + My Tasks keep native scroll. We throttle to one
     navigation per 220ms so a single trackpad swipe doesn't blow through
     three months. */
  const canvasRef = useRefA(null);
  const wheelLastTs = useRefA(0);
  const wheelAccum = useRefA(0);
  useEffectA(() => {
    const el = canvasRef.current;
    if (!el) return;
    if (view !== 'calendar') return;
    // Skip the wheel-driven date nav on touch / coarse-pointer devices
    // AND on narrow viewports (responsive testing / phones / tablets).
    // Touch devices emit wheel events from kinetic scrolls in a way
    // that would blow through several months per swipe; narrow viewports
    // typically mean the user is on mobile-scale where there's no
    // obvious affordance for the wheel-to-nav gesture.
    if (typeof window !== 'undefined') {
      if (window.matchMedia) {
        const mq = window.matchMedia('(hover: none), (pointer: coarse)');
        if (mq.matches) return;
      }
      if ((window.innerWidth || 9999) < 900) return;
    }
    const stepFor = (m) => {
      if (m === 'month') return 'month';
      if (m === 'fortnight') return 14;
      if (m === 'week' || m === 'lanes') return 7;
      // Timeline keeps native scroll — it's a horizontal Gantt that
      // users pan with the trackpad, so hijacking the wheel to advance
      // dates clashes with the panning gesture.
      return null;
    };
    const onWheel = e => {
      const step = stepFor(mode);
      if (!step) return;
      // Pinch-to-zoom (trackpad) and ctrl/⌘+wheel zoom arrive as wheel
      // events with a modifier set. Those are zoom gestures, not date
      // navigation — leave them entirely alone (no preventDefault) so the
      // browser zooms normally and the week cursor stays put.
      if (e.ctrlKey || e.metaKey) return;
      // Recheck the viewport at fire time so resizing the window mid-
      // session also disables the gesture.
      if (typeof window !== 'undefined' && (window.innerWidth || 9999) < 900) return;
      // Mostly-horizontal wheel events (sideways trackpad swipes) are
      // left alone — they shouldn't move the date cursor.
      if (Math.abs(e.deltaY) < Math.abs(e.deltaX)) return;
      // This is a vertical wheel in a date-nav mode, so the gesture belongs
      // to us, not to native scroll — consume it unconditionally. Without
      // this, sub-threshold deltas (below the accumulator gate) fall through
      // to the browser and natively scroll the canvas whenever the grid
      // overflows it (e.g. the 2-week view), which reads as "glitchy / not
      // fixed in place". Month view never overflowed so it never leaked;
      // preventing here makes both modes behave identically.
      e.preventDefault();
      // Accumulate small deltas so high-precision trackpads need a real
      // gesture before navigating, not a stray pixel.
      wheelAccum.current += e.deltaY;
      if (Math.abs(wheelAccum.current) < 28) return;
      const now = Date.now();
      if (now - wheelLastTs.current < 220) {
        // Throttle hits — eat the delta so it doesn't suddenly fire later.
        wheelAccum.current = 0;
        e.preventDefault();
        return;
      }
      const dir = wheelAccum.current > 0 ? 1 : -1;
      wheelAccum.current = 0;
      wheelLastTs.current = now;
      e.preventDefault();
      if (step === 'month') {
        const d = UA.parseISO(cursor);
        const next = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + dir, 1));
        setCursor(UA.formatISO(next));
      } else {
        setCursor(UA.addDays(cursor, step * dir));
      }
    };
    el.addEventListener('wheel', onWheel, { passive: false });
    return () => el.removeEventListener('wheel', onWheel);
  }, [view, mode, cursor]);

  const { TopBar, SubTabs } = window.mcalChrome;
  const { FilterBar, SettingsDrawer } = window.mcalChromeFB;
  const { DetailPanel } = window.mcalDetail;
  const { MonthView } = window.mcalMonth;
  const { WeekView, LanesView, TimelineView } = window.mcalCalViews;
  const { KanbanView } = window.mcalKanban;
  const { TableView } = window.mcalTable;
  const { MyTasksView } = window.mcalMyTasks || {};
  // The roster-driven owner list is woven into options before any view
  // sees it. Filters, color-by, hover cards, etc. read `owners` from
  // here — they never see the seed list anymore.
  const optsView = optionsWithOwners;

  return (
    <div className="app">
      <TopBar
        view={view} setView={setView}
        theme={theme} setTheme={setTheme}
        syncedAt={syncedAt}
        kind={window.mcalStore.kind}
        openSettings={openSettings}
        addEvent={addEvent}
        today={today}
        user={user}
        signOut={signOut}
      />
      {view === 'calendar' && (
        <SubTabs mode={mode} setMode={setMode} counts={counts} />
      )}
      <FilterBar
        mode={view === 'table' || view === 'mytasks' ? 'month' : mode}
        cursor={cursor} setCursor={setCursor}
        today={today}
        filters={filters} setFilters={setFilters}
        options={optsView}
        colorBy={colorBy} setColorBy={setColorBy}
        laneGroup={laneGroup} setLaneGroup={setLaneGroup}
        timelineGroup={timelineGroup} setTimelineGroup={setTimelineGroup}
        openSettingsTab={openSettingsTab}
      />
      <div className="main">
        <div className="canvas" ref={canvasRef}>
          {view === 'calendar' && mode === 'fortnight' && (
            <MonthView
              cursor={cursor} today={today}
              events={filtered} options={optsView}
              colorBy={colorBy}
              weeks={2}
              openEvent={openEvent}
              addEventAt={addEventAt}
              update={update}
              nestAsSubtask={nestAsSubtask}
              liftSubtask={liftSubtask}
              moveSubtask={moveSubtask}
              setCursor={setCursor}
            />
          )}
          {view === 'calendar' && mode === 'month' && (
            <MonthView
              cursor={cursor} today={today}
              events={filtered} options={optsView}
              colorBy={colorBy}
              openEvent={openEvent}
              addEventAt={addEventAt}
              update={update}
              nestAsSubtask={nestAsSubtask}
              liftSubtask={liftSubtask}
              moveSubtask={moveSubtask}
              setCursor={setCursor}
            />
          )}
          {view === 'calendar' && mode === 'week' && (
            <WeekView
              cursor={cursor} today={today}
              events={filtered} options={optsView}
              colorBy={colorBy}
              openEvent={openEvent}
              addEventAt={addEventAt}
              update={update}
              nestAsSubtask={nestAsSubtask}
              liftSubtask={liftSubtask}
              moveSubtask={moveSubtask}
              setCursor={setCursor}
            />
          )}
          {view === 'calendar' && mode === 'lanes' && (
            <LanesView
              cursor={cursor} today={today}
              events={filtered} options={optsView}
              colorBy={colorBy}
              laneGroup={laneGroup}
              openEvent={openEvent}
              addEventAt={addEventAt}
              update={update}
              nestAsSubtask={nestAsSubtask}
              liftSubtask={liftSubtask}
              moveSubtask={moveSubtask}
              setCursor={setCursor}
            />
          )}
          {view === 'calendar' && mode === 'timeline' && (
            <TimelineView
              cursor={cursor} today={today}
              events={filtered} options={optsView}
              colorBy={colorBy}
              timelineGroup={timelineGroup}
              openEvent={openEvent}
              addEventAt={addEventAt}
              update={update}
              nestAsSubtask={nestAsSubtask}
              liftSubtask={liftSubtask}
              moveSubtask={moveSubtask}
              setCursor={setCursor}
            />
          )}
          {view === 'calendar' && mode === 'kanban' && (
            <KanbanView
              today={today}
              events={filtered} options={optsView}
              colorBy={colorBy}
              openEvent={openEvent}
              addEventAt={addEventAt}
              update={update}
            />
          )}
          {view === 'table' && (
            <TableView
              events={filtered} options={optsView}
              today={today}
              openEvent={openEvent}
              update={update}
              remove={remove}
              selectedId={selectedId}
            />
          )}
          {view === 'mytasks' && MyTasksView && (
            <MyTasksView
              myOwnerId={myOwnerId}
              myTasks={myTasks}
              options={optsView}
              today={today}
              openEvent={openEvent}
              update={update}
              user={user}
            />
          )}
        </div>

        {detailLife.shown && (
          <DetailPanel
            event={detailLife.shown}
            options={optsView}
            update={update}
            remove={remove}
            close={closeDetail}
            leaving={detailLife.leaving}
            openSubtaskId={openedSubtaskId}
          />
        )}

        {settingsLife.shown && (
          <SettingsDrawer
            options={optsView} setOptions={setOptions}
            onClose={closeSettings}
            initialTab={settingsTab}
            leaving={settingsLife.leaving}
            rosterRpcStatus={rosterRpcStatus}
            updateOwner={updateOwner}
            gtmTeam={gtmTeam}
            setGtmTeam={setGtmTeam}
          />
        )}
      </div>
    </div>
  );
}

/* Root: Google sign-in gate restricted to @megaeth.com. When signed in,
   renders the App and passes user + signOut down for the topbar. */
function Root() {
  const { user, error, signIn, signOut } = window.mcalAuth.useAuth();
  if (!user) {
    return <window.mcalAuth.LoginScreen onSignIn={signIn} error={error} />;
  }
  return <App user={user} signOut={signOut} />;
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<Root />);
