// Komonichi store — fetch-based, microservice-aware.
// Preserves the prior surface (useKomonichi / actions.* / sel.*) so existing pages
// don't churn. Access token lives in memory; refresh token is in an httpOnly
// cookie. Bootstrap is a single /api/bootstrap call fanned out server-side.
(function () {
  "use strict";

  // discard legacy localStorage from the old single-user prototype, once.
  try { localStorage.removeItem("komonichi.state.v1"); } catch {}

  // ---------- low-level API ----------
  let accessToken = null;
  let onUnauth   = null;

  function setAccess(t) { accessToken = t; }
  function getAccess()  { return accessToken; }
  function setUnauthHandler(fn) { onUnauth = fn; }

  async function api(path, opts = {}) {
    const init = { ...opts, headers: { ...(opts.headers || {}) } };
    if (accessToken) init.headers["Authorization"] = "Bearer " + accessToken;
    if (init.body && typeof init.body === "object" && !(init.body instanceof FormData)) {
      init.headers["Content-Type"] = "application/json";
      init.body = JSON.stringify(init.body);
    }
    init.credentials = "include";
    let r = await fetch("/api" + path, init);
    if (r.status === 401 && path !== "/auth/refresh" && path !== "/auth/login" && path !== "/auth/signup") {
      const refreshed = await tryRefresh();
      if (refreshed) {
        init.headers["Authorization"] = "Bearer " + accessToken;
        r = await fetch("/api" + path, init);
      }
      if (r.status === 401) {
        if (onUnauth) onUnauth();
        throw new Error("unauthorized");
      }
    }
    if (!r.ok) {
      let body = null;
      try { body = await r.json(); } catch {}
      const msg = (body && body.error && body.error.message) || r.statusText;
      throw new Error(msg);
    }
    if (r.status === 204) return null;
    const ct = r.headers.get("content-type") || "";
    return ct.includes("application/json") ? r.json() : r.text();
  }

  async function tryRefresh() {
    try {
      const r = await fetch("/api/auth/refresh", { method: "POST", credentials: "include" });
      if (!r.ok) return false;
      const data = await r.json();
      accessToken = data.access_token;
      return true;
    } catch { return false; }
  }

  // ---------- store ----------
  function emptyGroupedObjectives() {
    const g = { yearly: [], quarterly: [], monthly: [], weekly: [] };
    Object.defineProperty(g, "flat", { value: [], enumerable: false });
    return g;
  }

  const initial = {
    user: null,
    profile: null,
    settings: { daily_focus_target_min: 120, pomodoro_min: 25, break_min: 5 },
    tasks: [],
    objectives: emptyGroupedObjectives(),
    areas: [],
    notes: {},
    log: {},
    badges: [],
    learn: [],
    ready: false,
  };

  const listeners = new Set();
  let state = initial;
  function getState() { return state; }
  function setState(updater) {
    state = typeof updater === "function" ? updater(state) : { ...state, ...updater };
    listeners.forEach((l) => l(state));
  }

  function useKomonichi() {
    const [, force] = React.useReducer((x) => x + 1, 0);
    React.useEffect(() => { listeners.add(force); return () => listeners.delete(force); }, []);
    return [state, setState];
  }

  // ---------- helpers ----------
  function todayKey(d = new Date()) {
    const y = d.getFullYear();
    const m = String(d.getMonth() + 1).padStart(2, "0");
    const dd = String(d.getDate()).padStart(2, "0");
    return `${y}-${m}-${dd}`;
  }
  function fmtMin(m) {
    m = Math.round(m || 0);
    if (m < 60) return `${m}m`;
    const h = Math.floor(m / 60);
    const r = m % 60;
    return r ? `${h}h ${r}m` : `${h}h`;
  }
  function uid() { return Math.random().toString(36).slice(2, 10); }

  // ---------- normalizers ----------
  // Bridge service-side snake_case + new status names back to the shape the
  // legacy pages already speak. New code can read either set of fields.
  // Carry both snake_case (new) and camelCase (legacy) so settings sliders &
  // dashboard buttons keep wiring.
  function normSettings(s) {
    return {
      ...s,
      dailyFocusTargetMin: s.daily_focus_target_min,
      pomodoroMin:         s.pomodoro_min,
      breakMin:            s.break_min,
    };
  }

  function normTask(t) {
    if (!t) return t;
    const legacyStatus =
      t.status === "completed" ? "done" :
      t.status === "archived"  ? "archived" : "open";
    return {
      ...t,
      id:          t.task_id,
      status:      legacyStatus,          // legacy: 'open' | 'done' | 'archived'
      raw_status:  t.status,              // server: 'open' | 'completed' | 'archived'
      completedAt: t.completed_at || null,
      objectiveId: t.objective_id || null,
      areaId:      t.area_id || null,
      dueDate:     t.due_date || null,    // YYYY-MM-DD or null
    };
  }
  function normArea(a) {
    if (!a) return a;
    // Legacy: an early build of the welcome seed wrote x/y as pixel coords
    // (e.g. 320, 180) instead of 0..1 fractions. Snap any out-of-range values
    // into a sensible position so old accounts still see their nodes.
    let { x, y } = a;
    let migrated = false;
    if (!(x >= 0 && x <= 1) || !(y >= 0 && y <= 1)) {
      const seed = String(a.area_id || a.name || "").split("").reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 360, 0);
      const rad = (seed / 360) * Math.PI * 2;
      x = 0.5 + Math.cos(rad) * 0.22;
      y = 0.5 + Math.sin(rad) * 0.18;
      migrated = true;
    }
    return { ...a, id: a.area_id, x, y, _migrated: migrated || undefined };
  }
  // The welcome seed always uses these exact phrases. Treat any objective row
  // with one of these as seeded so the "replace on first real add" behavior
  // works even for accounts created before the `seeded` column existed.
  const LEGACY_SEED_TEXT = new Set([
    "Cultivate a daily practice.",
    "Read four books slowly.",
    "Walk every morning.",
    "Write 500 quiet words.",
  ]);
  function normObjective(o) {
    if (!o) return o;
    const seeded = o.seeded || LEGACY_SEED_TEXT.has(o.text);
    return { ...o, id: o.objective_id, seeded };
  }

  // Group objectives into the {yearly,quarterly,monthly,weekly} shape legacy
  // pages expect.
  function groupObjectives(list) {
    const g = { yearly: [], quarterly: [], monthly: [], weekly: [] };
    for (const o of list) {
      if (g[o.period]) g[o.period].push(o);
    }
    // expose the flat list too via a non-enumerable property
    Object.defineProperty(g, "flat", { value: list, enumerable: false });
    return g;
  }

  // ---------- bootstrap ----------
  // Migrate any legacy area whose coords landed off-canvas. Best-effort —
  // failures are ignored, the client-side normalizer keeps them visible
  // either way.
  async function migrateLegacyAreas(areas) {
    for (const a of areas) {
      if (a._migrated) {
        try { await api(`/areas/${a.area_id}`, { method: "PATCH", body: { x: a.x, y: a.y } }); }
        catch {}
      }
    }
  }

  async function bootstrap() {
    const data = await api("/bootstrap");
    setState((s) => ({
      ...s,
      profile:    data.profile,
      settings:   normSettings({ ...s.settings, ...(data.settings || {}) }),
      tasks:      (data.tasks      || []).map(normTask),
      objectives: groupObjectives((data.objectives || []).map(normObjective)),
      areas:      (data.areas      || []).map(normArea),
      // (migration writes are kicked off in the effect below)
      log:        data.log        || {},
      badges:     data.badges     || [],
      learn:      data.learn      || [],
      ready: true,
    }));
    migrateLegacyAreas((data.areas || []).map(normArea));
  }

  async function loadNoteForDate(date) {
    const r = await api(`/notes/${date}`);
    setState((s) => ({ ...s, notes: { ...s.notes, [date]: r.body || "" } }));
  }

  // ---------- actions ----------
  const actions = {
    async addTask(input) {
      const t = normTask(await api("/tasks", { method: "POST", body: input }));
      setState((s) => ({ ...s, tasks: [...s.tasks, t] }));
      return t;
    },
    async editTask(id, patch) {
      const t = normTask(await api(`/tasks/${id}`, { method: "PATCH", body: patch }));
      setState((s) => ({ ...s, tasks: s.tasks.map((x) => (x.task_id === id || x.id === id) ? t : x) }));
      return t;
    },
    async toggleTaskDone(id) {
      const cur = state.tasks.find((t) => t.task_id === id || t.id === id);
      // normalized 'done' === server 'completed'
      const isDone = cur && (cur.raw_status === "completed" || cur.status === "done");
      const next = isDone ? "open" : "completed";
      return actions.editTask(cur ? cur.task_id : id, { status: next });
    },
    async archiveTask(id)  { return actions.editTask(id, { status: "archived" }); },
    // Bulk: move every overdue open task's due_date to today, atomically
    // (single round-trip; service does a DynamoDB TransactWrite per chunk).
    async rescheduleOverdueToToday() {
      const today = todayKey();
      const r = await api("/tasks/reschedule-overdue", {
        method: "POST", body: { target_date: today },
      });
      const updated = (r && r.updated || []).map(normTask);
      if (!updated.length) return [];
      setState((s) => {
        const byId = new Map(updated.map((u) => [u.task_id, u]));
        return { ...s, tasks: s.tasks.map((x) => byId.get(x.task_id) || x) };
      });
      return updated;
    },
    async recreateTask(id) {
      const t = normTask(await api("/tasks", { method: "POST", body: { clone_of: id } }));
      setState((s) => ({ ...s, tasks: [...s.tasks, t] }));
      return t;
    },
    // soft-delete shim for legacy callers expecting removeTask:
    async removeTask(id)  { return actions.archiveTask(id); },

    async addObjective(text, period, parent_id, opts) {
      const seeded = !!(opts && opts.seeded);
      const o = normObjective(await api("/objectives", { method: "POST", body: { text, period, parent_id, seeded } }));

      // If this is a real (user-authored) objective, retire any seeded
      // placeholder in the same period — that's the "default goes away when
      // you commit your own" behavior.
      let toArchive = [];
      if (!seeded) {
        const existing = (state.objectives.flat || []).filter(
          (x) => x.period === period && x.seeded && x.status !== "archived"
        );
        toArchive = existing;
      }

      setState((s) => {
        const flat = [...(s.objectives.flat || []).filter((x) => !toArchive.some((a) => a.id === x.id)), o];
        return { ...s, objectives: groupObjectives(flat) };
      });

      // fire-and-forget the soft-archive calls
      for (const old of toArchive) {
        api(`/objectives/${old.period}/${old.id}`, {
          method: "PATCH", body: { status: "archived" },
        }).catch(() => {});
      }
      return o;
    },

    async addArea(input) {
      const a = normArea(await api("/areas", { method: "POST", body: input }));
      setState((s) => ({ ...s, areas: [...s.areas, a] }));
      return a;
    },
    async moveArea(id, x, y) {
      const a = normArea(await api(`/areas/${id}`, { method: "PATCH", body: { x, y } }));
      setState((s) => ({ ...s, areas: s.areas.map((n) => n.area_id === id ? a : n) }));
      return a;
    },
    async editArea(id, patch) {
      const a = normArea(await api(`/areas/${id}`, { method: "PATCH", body: patch }));
      setState((s) => ({ ...s, areas: s.areas.map((n) => n.area_id === id ? a : n) }));
      return a;
    },
    async removeArea(id) {
      const a = await api(`/areas/${id}`, { method: "PATCH", body: { status: "archived" } });
      setState((s) => ({ ...s, areas: s.areas.filter((n) => n.area_id !== id) }));
      return a;
    },

    loadNoteForDate,
    async setDailyNote(date, body) {
      setState((s) => ({ ...s, notes: { ...s.notes, [date]: body } })); // optimistic
      await api(`/notes/${date}`, { method: "PUT", body: { body } });
    },

    async logSession({ taskId, minutes, mode }) {
      const out = await api("/focus/sessions", {
        method: "POST",
        body: { task_id: taskId, minutes, mode, completed_at: new Date().toISOString() },
      });
      const [log, badges] = await Promise.all([api("/focus/log"), api("/focus/badges")]);
      setState((s) => ({ ...s, log, badges }));
      return out;
    },

    async setSettings(patch) {
      // accept either camelCase or snake_case from callers.
      const map = {
        dailyFocusTargetMin: "daily_focus_target_min",
        pomodoroMin:         "pomodoro_min",
        breakMin:            "break_min",
      };
      const serverPatch = {};
      for (const [k, v] of Object.entries(patch)) {
        serverPatch[map[k] || k] = v;
      }
      await api("/profile/settings", { method: "PATCH", body: serverPatch });
      setState((s) => ({ ...s, settings: normSettings({ ...s.settings, ...serverPatch }) }));
    },

    async setDisplayName(display_name) {
      await api("/profile/me", { method: "PATCH", body: { display_name } });
      setState((s) => ({ ...s, profile: { ...(s.profile || {}), display_name } }));
    },
    async uploadAvatarDataUri(data_uri) {
      const out = await api("/profile/avatar", { method: "POST", body: { data_uri } });
      setState((s) => ({ ...s, profile: { ...(s.profile || {}), avatar_url: out.avatar_url } }));
      return out;
    },
    async deleteAccount({ password }) {
      await api("/auth/account", {
        method: "DELETE",
        body: { confirm: "DELETE", current_password: password },
      });
      setAccess(null);
    },
    async clearAvatar() {
      await api("/profile/avatar", { method: "DELETE" });
      setState((s) => {
        const next = { ...(s.profile || {}) };
        delete next.avatar_url;
        return { ...s, profile: next };
      });
    },

    async markLearn(practice_key, status) {
      const row = await api("/learn/progress", { method: "POST", body: { practice_key, status } });
      setState((s) => {
        const without = s.learn.filter((r) => r.practice_key !== practice_key);
        return { ...s, learn: [...without, row] };
      });
      return row;
    },
  };

  // ---------- selectors ----------
  const sel = {
    todayLog()        { return state.log[todayKey()] || { totalMin: 0, sessions: [] }; },
    todayMinutes()    { return sel.todayLog().totalMin; },
    todayTargetPct()  {
      const target = state.settings.daily_focus_target_min || 120;
      return Math.min(100, Math.round((sel.todayMinutes() / target) * 100));
    },
    totalFocusMin()   { return Object.values(state.log).reduce((a, d) => a + (d.totalMin || 0), 0); },
    totalSessions()   { return Object.values(state.log).reduce((a, d) => a + (d.sessions ? d.sessions.length : 0), 0); },
    streakDays() {
      let n = 0;
      const d = new Date();
      for (;;) {
        const k = todayKey(d);
        if (!state.log[k] || !state.log[k].totalMin) break;
        n++; d.setDate(d.getDate() - 1);
      }
      return n;
    },
    last14() {
      const out = [];
      const d = new Date(); d.setDate(d.getDate() - 13);
      for (let i = 0; i < 14; i++) {
        const k = todayKey(d);
        const min = (state.log[k] && state.log[k].totalMin) || 0;
        out.push({ date: k, day: new Date(d), min, minutes: min });
        d.setDate(d.getDate() + 1);
      }
      return out;
    },
    taskById(id)      { return state.tasks.find((t) => t.task_id === id) || null; },
    objectiveById(id) {
      // state.objectives is the grouped object after bootstrap; its non-enumerable
      // `flat` carries the full list. Fall back to an empty array pre-bootstrap.
      const all = (state.objectives && state.objectives.flat) ||
                  (Array.isArray(state.objectives) ? state.objectives : []);
      return all.find((o) => o.objective_id === id || o.id === id) || null;
    },
    areaById(id)      { return state.areas.find((a) => a.area_id === id) || null; },
    tasksByArea(areaId) {
      if (!areaId || areaId === "all") return state.tasks;
      if (areaId === "none") return state.tasks.filter((t) => !t.area_id);
      return state.tasks.filter((t) => t.area_id === areaId);
    },
    openTasks()       { return state.tasks.filter((t) => t.status === "open"); },
    completedTasks()  { return state.tasks.filter((t) => t.status === "completed"); },
    learnStatusFor(key) {
      const r = state.learn.find((x) => x.practice_key === key);
      return r ? r.status : null;
    },
  };

  window.KomonichiState = {
    useKomonichi, getState, setState,
    actions, sel,
    todayKey, fmtMin, uid,
    api, setAccess, getAccess, setUnauthHandler, tryRefresh, bootstrap, loadNoteForDate,
  };
})();
