/* ============================================================
   STORE — global app state with useSyncExternalStore
   + helpers (Bengali numerals, relative time)
   Exposes window.useApp, window.AppStore, window.H (helpers)

   The store calls the Ktor backend via window.API for every action that
   mutates server-side data, then mirrors the result into state. Components
   read articles/feeds/queue/staff from app.state; window.SEED only carries
   the static CATEGORIES constants (data.jsx).
   ============================================================ */
(function () {
  const API = window.API;
  if (!API) throw new Error("api.jsx must be loaded before store.jsx");

  /* ---------- helpers (also exported as window.H) ---------- */
  const BN_DIGITS = ["০","১","২","৩","৪","৫","৬","৭","৮","৯"];
  function toBn(n) { return String(n).replace(/[0-9]/g, d => BN_DIGITS[+d]); }
  function bnNum(n) {
    if (n >= 100000) return toBn((n / 100000).toFixed(n % 100000 === 0 ? 0 : 1)) + " লাখ";
    if (n >= 1000)   return toBn((n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)) + " হাজার";
    return toBn(n);
  }
  function enNum(n) {
    if (n >= 1_000_000) return (n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1) + "M";
    if (n >= 1000)      return (n / 1000).toFixed(n % 1000 === 0 ? 0 : 1) + "K";
    return String(n);
  }
  function curLang() { return (window.AppStore && window.AppStore.state && window.AppStore.state.lang) || "bn"; }
  function num(n)    { return curLang() === "en" ? String(n) : toBn(n); }
  function numShort(n) { return curLang() === "en" ? enNum(n) : bnNum(n); }
  function rel(ts) {
    if (!ts) return "—";
    const en = curLang() === "en";
    const diff = Date.now() - ts, m = Math.floor(diff / 60000);
    if (m < 1) return en ? "just now" : "এইমাত্র";
    if (m < 60) return en ? m + " min ago" : toBn(m) + " মিনিট আগে";
    const h = Math.floor(m / 60);
    if (h < 24) return en ? h + " hr ago" : toBn(h) + " ঘণ্টা আগে";
    const d = Math.floor(h / 24);
    return en ? d + " day" + (d === 1 ? "" : "s") + " ago" : toBn(d) + " দিন আগে";
  }
  const MONTHS_BN = ["জানুয়ারি","ফেব্রুয়ারি","মার্চ","এপ্রিল","মে","জুন","জুলাই","আগস্ট","সেপ্টেম্বর","অক্টোবর","নভেম্বর","ডিসেম্বর"];
  const MONTHS_EN = ["January","February","March","April","May","June","July","August","September","October","November","December"];
  const DAYS_BN   = ["রবিবার","সোমবার","মঙ্গলবার","বুধবার","বৃহস্পতিবার","শুক্রবার","শনিবার"];
  const DAYS_EN   = ["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];
  function fullDate(d = new Date()) {
    if (curLang() === "en") {
      return `${DAYS_EN[d.getDay()]}, ${d.getDate()} ${MONTHS_EN[d.getMonth()]} ${d.getFullYear()}`;
    }
    return `${DAYS_BN[d.getDay()]}, ${toBn(d.getDate())} ${MONTHS_BN[d.getMonth()]} ${toBn(d.getFullYear())}`;
  }
  function catBy(slug) { return window.SEED.CATEGORIES.find(c => c.slug === slug) || { slug, bn: slug, en: slug }; }

  /**
   * pick(obj, kind) — return the language-appropriate field on obj.
   * Supported `kind` values:
   *   "title"   → obj.bn / obj.en   (articles)
   *   "excerpt" → obj.excerpt_bn / obj.excerpt_en
   *   "author"  → obj.author_bn (a.author) / obj.author_en
   *   "label"   → obj.bn / obj.en   (categories)
   * Falls back to Bengali when an English value is missing.
   */
  function pick(obj, kind) {
    if (!obj) return "";
    const en = curLang() === "en";
    switch (kind) {
      case "title":
        return en ? (obj.en || obj.bn || "") : (obj.bn || obj.en || "");
      case "excerpt":
        return en ? (obj.excerpt_en || obj.excerpt_bn || "") : (obj.excerpt_bn || obj.excerpt_en || "");
      case "author":
        return en
          ? (obj.author_en || obj.reporter_en || obj.author || obj.author_bn || "")
          : (obj.author_bn || obj.author || obj.author_en || "");
      case "label":
        return en ? (obj.en || obj.bn || "") : (obj.bn || obj.en || "");
      default:
        return "";
    }
  }

  /** UI dictionary. Add keys here when new strings need translating. */
  const I18N = {
    bn: {
      home: "প্রচ্ছদ",
      admin: "অ্যাডমিন",
      english: "English",
      bangla: "বাংলা",
      featured: "বিশেষ সংবাদ",
      moreFeatured: "আরও বিশেষ খবর",
      mostRead: "সর্বাধিক পঠিত",
      seeAll: "সব দেখুন",
      slide: "স্লাইড",
      prev: "আগের",
      next: "পরের",
      video: "ভিডিও",
      source: "সূত্র",
      minRead: "মিনিট পড়া",
      save: "সংরক্ষণ",
      saved: "সংরক্ষিত",
      linkCopied: "লিংক কপি হয়েছে",
      imageCaption: "ছবি: প্রতিনিধি · বিষয়ভিত্তিক চিত্র",
      related: "সম্পর্কিত খবর",
      readMore: "আরও পড়ুন",
      categories: "বিভাগসমূহ",
      organization: "প্রতিষ্ঠান",
      loading: "লোড হচ্ছে…",
      noResults: "কোনো ফলাফল পাওয়া যায়নি",
      typeToSearch: "টাইপ করে খোঁজা শুরু করুন",
      searchPlaceholder: "সংবাদ, বিষয় বা লেখক খুঁজুন…",
      bookmarks: "সংরক্ষিত সংবাদ",
      noBookmarks: "এখনো কিছু সংরক্ষণ করা হয়নি",
      bookmarkHint: "যেকোনো সংবাদে বুকমার্ক আইকনে ক্লিক করে পরে পড়ার জন্য রাখুন।",
      readNews: "সংবাদ পড়ুন",
      logout: "লগআউট",
      backToBookmarks: "সংরক্ষিত সংবাদ দেখুন",
      welcomeBack: "স্বাগতম",
      newAccount: "নতুন অ্যাকাউন্ট",
      loginSubtitle: "আপনার অ্যাকাউন্টে প্রবেশ করুন",
      signupSubtitle: "কয়েক সেকেন্ডেই শুরু করুন",
      fullName: "পূর্ণ নাম",
      email: "ইমেইল",
      password: "পাসওয়ার্ড",
      loginBtn: "প্রবেশ করুন",
      signupBtn: "অ্যাকাউন্ট তৈরি করুন",
      or: "অথবা",
      continueAsGuest: "অতিথি হিসেবে চালিয়ে যান",
      noAccount: "অ্যাকাউন্ট নেই?",
      haveAccount: "ইতিমধ্যে অ্যাকাউন্ট আছে?",
      register: "নিবন্ধন করুন",
      login: "প্রবেশ করুন",
      reactions: "প্রতিক্রিয়া",
      read: "পঠিত",
      notFound: "সংবাদটি পাওয়া যায়নি।",
      noCategoryNews: "এই বিভাগে এখনো কোনো সংবাদ নেই।",
      sortNew: "নতুন",
      sortTop: "জনপ্রিয়",
      totalNews: "মোট সংবাদ",
      yourReaction: "আপনার প্রতিক্রিয়া",
      tagline: "একটি স্বাধীন বাংলা সংবাদমাধ্যম — সত্য, সংযম ও সম্পাদকীয় নিষ্ঠার অঙ্গীকারে।",
      allRights: "সর্বস্বত্ব সংরক্ষিত।",
      menu: "বিভাগ",
      catLabel: (cat) => `${cat.bn} বিভাগের সর্বশেষ সংবাদ, বিশ্লেষণ ও প্রতিবেদন এক জায়গায়।`,
    },
    en: {
      home: "Home",
      admin: "Admin",
      english: "English",
      bangla: "Bangla",
      featured: "Featured",
      moreFeatured: "More Featured",
      mostRead: "Most Read",
      seeAll: "See all",
      slide: "Slide",
      prev: "Previous",
      next: "Next",
      video: "Video",
      source: "Source",
      minRead: "min read",
      save: "Save",
      saved: "Saved",
      linkCopied: "Link copied",
      imageCaption: "Photo: Staff · illustrative",
      related: "Related News",
      readMore: "Read more",
      categories: "Categories",
      organization: "Organization",
      loading: "Loading…",
      noResults: "No results found",
      typeToSearch: "Start typing to search",
      searchPlaceholder: "Search news, topics or authors…",
      bookmarks: "Saved News",
      noBookmarks: "Nothing saved yet",
      bookmarkHint: "Tap the bookmark icon on any article to save it for later.",
      readNews: "Read News",
      logout: "Logout",
      backToBookmarks: "View Saved News",
      welcomeBack: "Welcome back",
      newAccount: "New account",
      loginSubtitle: "Log in to your account",
      signupSubtitle: "Get started in seconds",
      fullName: "Full name",
      email: "Email",
      password: "Password",
      loginBtn: "Log in",
      signupBtn: "Create account",
      or: "or",
      continueAsGuest: "Continue as guest",
      noAccount: "No account?",
      haveAccount: "Already have an account?",
      register: "Register",
      login: "Log in",
      reactions: "Reactions",
      read: "Read",
      notFound: "Article not found.",
      noCategoryNews: "No news in this category yet.",
      sortNew: "Newest",
      sortTop: "Popular",
      totalNews: "Total articles",
      yourReaction: "Your reaction",
      tagline: "An independent Bangla news outlet — committed to truth, restraint and editorial discipline.",
      allRights: "All rights reserved.",
      menu: "Categories",
      catLabel: (cat) => `Latest news, analysis and reports from the ${cat.en} desk.`,
    },
  };

  function t(key, arg) {
    const dict = I18N[curLang()] || I18N.bn;
    const v = dict[key];
    if (typeof v === "function") return v(arg);
    if (v != null) return v;
    return I18N.bn[key] || key;
  }

  window.H = { toBn, bnNum, num, numShort, rel, fullDate, catBy, pick, t };

  /* ---------- DTO mappers (backend → frontend shape) ---------- */
  function mapArticle(a) {
    if (!a) return null;
    const bodyBnStr = a.bodyBn || "";
    const bodyEnStr = a.bodyEn || "";
    const ts = a.publishedAtMs || Date.now();
    const mediaUrl = a.mediaUrl ? API.imgUrl(a.mediaUrl) : null;
    const mediaThumb = a.mediaThumbnail ? API.imgUrl(a.mediaThumbnail) : null;
    const legacyImg = a.imagePath ? API.imgUrl(a.imagePath) : null;
    return {
      id: a.id,
      cat: a.cat,
      bn: a.titleBn,
      en: a.titleEn,
      excerpt_bn: a.excerptBn,
      excerpt_en: a.excerptEn,
      body:    bodyBnStr,                                    // for editor (single string)
      // body_{bn,en} are kept as raw strings. consumer-pages decides per-render
      // whether to split on \n\n (plain text) or render as one HTML block
      // (raw RSS bodies that ship <p>…</p> with no blank-line separators).
      body_bn: bodyBnStr,
      body_en: bodyEnStr,
      author: a.authorBn,
      author_bn: a.authorBn,
      author_en: a.authorEn,
      reporter_en: a.authorEn,
      img: legacyImg || mediaThumb || (a.mediaKind === "image" ? mediaUrl : null),
      mediaKind: a.mediaKind || (legacyImg ? "image" : "none"),
      mediaUrl,
      mediaMime: a.mediaMime || null,
      mediaThumbnail: mediaThumb || legacyImg,
      mediaWidth: a.mediaWidth || null,
      mediaHeight: a.mediaHeight || null,
      mediaDuration: a.mediaDuration || null,
      source: a.source,
      feed: a.feedName || null,
      sourceLink: a.sourceLink || null,
      bodySource: a.bodySource || null,
      special: !!a.special,
      mins: a.mins || 3,
      views: a.views || 0,
      ts,
      status: a.status,
      reactions: a.reactions || { up: 0, love: 0, insightful: 0, sad: 0, angry: 0 },
    };
  }

  function mapFeed(f) {
    if (!f) return null;
    return {
      id: f.id,
      name: f.name,
      url: f.url,
      cat: f.cat,
      status: f.status,
      mode: f.mode,
      interval: f.intervalMinutes,
      items: f.itemCount,
      lastSync: rel(f.lastSyncAtMs),
    };
  }

  function mapQueueItem(q) {
    if (!q) return null;
    return {
      id: q.id,
      feed: q.feedName,
      cat: q.cat,
      ts: q.fetchedAtMs || Date.now(),
      bn: q.titleBn,
      en: q.titleEn,
      excerpt_bn: q.excerptBn,
      excerpt_en: q.excerptEn,
      mediaKind: q.mediaKind || "none",
      mediaUrl: q.mediaUrl ? API.imgUrl(q.mediaUrl) : null,
      mediaMime: q.mediaMime || null,
      mediaThumbnail: q.mediaThumbnail ? API.imgUrl(q.mediaThumbnail) : (q.imagePath ? API.imgUrl(q.imagePath) : null),
    };
  }

  /* backend error codes (ErrorDto.message) → human-readable Bengali */
  const API_ERR_BN = {
    missing_fields:     "শিরোনাম, সারাংশ ও মূল বিবরণ পূরণ করুন",
    invalid_category:   "বিভাগটি সঠিক নয়",
    invalid_status:     "অবস্থার মান সঠিক নয়",
    invalid_media_kind: "মিডিয়া ধরনটি সঠিক নয়",
    invalid_mins:       "পড়ার সময় ১–১২০ মিনিটের মধ্যে দিন",
    forbidden:          "এই কাজের অনুমতি আপনার নেই",
    unauthorized:       "সেশনের মেয়াদ শেষ — আবার লগইন করুন",
    not_found:          "পোস্টটি পাওয়া যায়নি",
  };
  function apiErrMsg(e, fallback) {
    if (!e) return fallback;
    if (e.kind === "network") return e.message; // already Bengali
    const code = e.message || "";
    if (API_ERR_BN[code]) return API_ERR_BN[code];
    if (code.startsWith("too_long:")) return "একটি ঘরের লেখা খুব দীর্ঘ — ছোট করুন";
    if (code.startsWith("missing_permission:")) return API_ERR_BN.forbidden;
    if (code.startsWith("missing_category")) return "এই বিভাগে কাজ করার অনুমতি নেই";
    if (API_ERR_BN[e.error]) return API_ERR_BN[e.error];
    return fallback;
  }

  function mapStaff(s) {
    if (!s) return null;
    const p = s.perms || {};
    return {
      id: s.id,
      name: s.nameBn,
      en: s.nameEn,
      email: s.email,
      role: s.role,
      status: s.status,
      cats: s.cats || [],
      initials: s.initials,
      last: rel(s.lastLoginAtMs),
      perms: {
        create:      !!p.canCreate,
        edit:        !!p.canEdit,
        publish:     !!p.canPublish,
        delete:      !!p.canDelete,
        rss:         !!p.canManageRss,
        manageUsers: !!p.canManageUsers,
      },
    };
  }

  function mapStaffProfile(profile) {
    if (!profile) return null;
    const p = profile.perms || {};
    return {
      id: profile.id,
      name: profile.name,
      en: profile.nameEn || profile.name,
      email: profile.email,
      initials: profile.initials,
      role: profile.role || "moderator",
      cats: profile.cats || [],
      status: "active",
      last: "এখন সক্রিয়",
      perms: {
        create:      !!p.canCreate,
        edit:        !!p.canEdit,
        publish:     !!p.canPublish,
        delete:      !!p.canDelete,
        rss:         !!p.canManageRss,
        manageUsers: !!p.canManageUsers,
      },
    };
  }

  /* ---------- hash routing (view/params ↔ location.hash) ---------- */
  // home→#/  category→#/category/:slug  article→#/article/:id  login/bookmarks/account→#/<view>  page→#/page/:slug
  // admin→#/admin  admin-posts→#/admin/posts  admin-editor→#/admin/editor[/:id]  admin-rss/people→#/admin/<sub>
  function routeToHash(route) {
    const { view, params } = route;
    switch (view) {
      case "home":     return "/";
      case "category": return "/category/" + encodeURIComponent(params.slug || "");
      case "article":  return "/article/" + encodeURIComponent(params.id != null ? params.id : "");
      case "page":     return "/page/" + encodeURIComponent(params.slug || "");
      case "login":
      case "bookmarks":
      case "account":  return "/" + view;
      case "admin":    return "/admin";
      case "admin-posts":  return "/admin/posts";
      case "admin-editor": return params.id != null ? "/admin/editor/" + encodeURIComponent(params.id) : "/admin/editor";
      case "admin-rss":    return "/admin/rss";
      case "admin-people": return "/admin/people";
      default: return "/";
    }
  }

  function parseHash(hash) {
    const HOME = { view: "home", params: {} };
    const path = String(hash || "").replace(/^#\/?/, "");
    if (!path) return HOME;
    const seg = path.split("/").filter(Boolean).map(s => {
      try { return decodeURIComponent(s); } catch (_) { return s; }
    });
    // ids are numeric on the backend; components compare with === so coerce digit-only segments
    const asId = s => (/^\d+$/.test(s) ? Number(s) : s);
    if (seg[0] === "category" && seg[1]) return { view: "category", params: { slug: seg[1] } };
    if (seg[0] === "article" && seg[1])  return { view: "article", params: { id: asId(seg[1]) } };
    if (seg[0] === "page" && seg[1])     return { view: "page", params: { slug: seg[1] } };
    if (seg[0] === "login" || seg[0] === "bookmarks" || seg[0] === "account") return { view: seg[0], params: {} };
    if (seg[0] === "admin") {
      if (!seg[1]) return { view: "admin", params: {} };
      if (seg[1] === "posts")  return { view: "admin-posts", params: {} };
      if (seg[1] === "editor") return { view: "admin-editor", params: seg[2] != null ? { id: asId(seg[2]) } : {} };
      if (seg[1] === "rss")    return { view: "admin-rss", params: {} };
      if (seg[1] === "people") return { view: "admin-people", params: {} };
      return { view: "admin", params: {} };
    }
    return HOME;
  }

  /* ---------- initial state ---------- */
  const initial = {
    route: parseHash(location.hash),
    lang: "en",
    user: null,                  // consumer profile {id, name, email, initials}
    staffUser: null,             // logged-in admin profile (mapped)
    myReactions: {},             // {articleId: 'up'|'love'|...}
    bookmarks: [],               // [articleId]
    articleState: {},            // {articleId: { views, reactions }}
    articles: [],
    featured: [],
    posts: [],
    feeds: [],
    queue: [],
    aliases: [],                 // RSS term → category mappings [{term, cat, priority}]
    staff: [],
    dashboard: null,
    currentStaff: null,          // legacy: kept as staffUser.id for components that still read it
    toast: null,
    booting: true,
    loadError: null,             // last loadPublic() failure (network/HTTP) so UI can show retry
  };

  const listeners = new Set();
  const AppStore = {
    state: initial,
    subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); },
    set(patch) {
      const next = typeof patch === "function" ? patch(this.state) : patch;
      this.state = Object.assign({}, this.state, next);
      listeners.forEach(l => l());
    },

    /* ---------- navigation ---------- */
    nav(view, params = {}) {
      this.set({ route: { view, params } });
      const hash = "#" + routeToHash(this.state.route);
      if (location.hash !== hash) location.hash = hash; // pushes a history entry, fires hashchange
      window.scrollTo({ top: 0, behavior: "instant" in window ? "instant" : "auto" });
    },

    /* ---------- language ---------- */
    setLang(lang) {
      const next = lang === "en" ? "en" : "bn";
      try { localStorage.setItem("probhat_lang", next); } catch (_) {}
      this.set({ lang: next });
      try { document.documentElement.setAttribute("lang", next === "en" ? "en" : "bn"); } catch (_) {}
    },
    toggleLang() {
      this.setLang(this.state.lang === "bn" ? "en" : "bn");
    },

    /* ---------- public data loaders ---------- */
    async loadPublic() {
      try {
        const [articles, featured] = await Promise.all([
          API.publicArticles.list({ limit: 200 }),
          API.publicArticles.featured(10),
        ]);
        const mapped = (articles || []).map(mapArticle);
        const mappedFeatured = (featured || []).map(mapArticle);
        const articleState = {};
        mapped.forEach(a => { articleState[a.id] = { views: a.views, reactions: a.reactions }; });
        this.set({ articles: mapped, featured: mappedFeatured, articleState, loadError: null });
      } catch (e) {
        console.warn("loadPublic failed", e);
        this.set({ loadError: e && (e.message || e.kind) ? (e.message || e.kind) : "load_failed" });
      }
    },

    async loadUserData() {
      const token = API.getUserToken();
      if (!token) return;
      try {
        const [bm, rx] = await Promise.all([API.me.bookmarks(), API.me.reactions().catch(() => null)]);
        this.set({ bookmarks: bm.ids || [], myReactions: (rx && rx.reactions) || {} });
      } catch (e) {
        if (e.status === 401) { API.setUserToken(null); this.set({ user: null, bookmarks: [], myReactions: {} }); }
      }
    },

    async loadAdminData() {
      const token = API.getStaffToken();
      if (!token) return;
      try {
        const [posts, feeds, queue, aliases, staff, dashboard] = await Promise.all([
          API.admin.articles.list(),
          API.admin.feeds.list(),
          API.admin.queue.list(),
          API.admin.categoryAliases.list().catch(() => []),
          API.admin.staff.list(),
          API.admin.dashboard().catch(() => null),
        ]);
        this.set({
          posts:    (posts  || []).map(mapArticle),
          feeds:    (feeds  || []).map(mapFeed),
          queue:    (queue  || []).map(mapQueueItem),
          aliases:  aliases || [],
          staff:    (staff  || []).map(mapStaff),
          dashboard,
        });
      } catch (e) {
        if (e.status === 401) { this.staffLogout(); }
        else console.warn("loadAdminData failed", e);
      }
    },

    async refreshAdminPosts() {
      if (!API.getStaffToken()) return;
      try {
        const posts = await API.admin.articles.list();
        this.set({ posts: (posts || []).map(mapArticle) });
      } catch (_) {}
    },

    /* ---------- consumer auth ---------- */
    async login(name, email, password, mode) {
      mode = mode || "login";
      const pw = password || "probhat_demo";
      try {
        let r;
        if (mode === "signup") {
          r = await API.auth.register(name || "পাঠক", email, pw);
        } else {
          try {
            r = await API.auth.login(email, pw);
          } catch (err) {
            // attempt auto-register on first guest sign-in
            if (err.status === 401) r = await API.auth.register(name || "পাঠক", email, pw);
            else throw err;
          }
        }
        API.setUserToken(r.token);
        this.set({ user: { id: r.profile.id, name: r.profile.name, email: r.profile.email, initials: r.profile.initials } });
        await this.loadUserData();
        this.toast(`স্বাগতম, ${r.profile.name || "পাঠক"}!`);
      } catch (e) {
        this.toast(e.message || "প্রবেশ ব্যর্থ");
        throw e;
      }
    },
    logout() {
      API.setUserToken(null);
      this.set({ user: null, bookmarks: [], myReactions: {} });
      this.toast("লগআউট হয়েছে");
    },

    /* ---------- admin auth ---------- */
    async staffLogin(email, password) {
      try {
        const r = await API.admin.login(email, password);
        API.setStaffToken(r.token);
        const me = mapStaffProfile(r.profile);
        this.set({ staffUser: me, currentStaff: me.id });
        await this.loadAdminData();
        this.toast(`স্বাগতম, ${me.name}`);
      } catch (e) {
        this.toast(e.message || "প্রবেশ ব্যর্থ");
        throw e;
      }
    },
    staffLogout() {
      API.setStaffToken(null);
      this.set({ staffUser: null, currentStaff: null, posts: [], feeds: [], queue: [], aliases: [], staff: [], dashboard: null });
    },

    /* ---------- reactions (optimistic, server-reconciled) ---------- */
    syncArticleState(articleId, patch) {
      const as = Object.assign({}, this.state.articleState);
      as[articleId] = Object.assign({}, as[articleId], patch);
      this.set({ articleState: as });
    },

    async react(articleId, kind) {
      if (!this.state.user) { this.nav("login"); return; }
      const prevMy = this.state.myReactions;
      const prevAs = this.state.articleState;

      // optimistic local update
      const my = Object.assign({}, prevMy);
      const r = Object.assign({ up:0, love:0, insightful:0, sad:0, angry:0 }, (prevAs[articleId] || {}).reactions);
      const prev = my[articleId];
      if (prev === kind) { delete my[articleId]; r[kind] = Math.max(0, r[kind] - 1); }
      else { if (prev) r[prev] = Math.max(0, r[prev] - 1); my[articleId] = kind; r[kind] = (r[kind] || 0) + 1; }
      this.set({ myReactions: my });
      this.syncArticleState(articleId, { reactions: r });

      try {
        // server is authoritative: it returns the final counts and my resulting reaction
        const result = await API.me.react(articleId, kind);
        if (result && result.reactions) {
          const mine = Object.assign({}, this.state.myReactions);
          if (result.myReaction) mine[articleId] = result.myReaction;
          else delete mine[articleId];
          this.set({ myReactions: mine });
          this.syncArticleState(articleId, { reactions: result.reactions });
        }
      } catch (e) {
        console.warn("react failed", e);
        this.set({ myReactions: prevMy, articleState: prevAs });
      }
    },

    async toggleBookmark(articleId) {
      if (!this.state.user) { this.nav("login"); return; }
      const has = this.state.bookmarks.includes(articleId);
      const next = has ? this.state.bookmarks.filter(x => x !== articleId) : [...this.state.bookmarks, articleId];
      this.set({ bookmarks: next });
      try {
        if (has) await API.me.removeBookmark(articleId);
        else     await API.me.addBookmark(articleId);
        this.toast(has ? "সংরক্ষণ সরানো হয়েছে" : "সংরক্ষণ করা হয়েছে");
      } catch (e) {
        this.set({ bookmarks: this.state.bookmarks });
      }
    },

    addView(articleId) {
      const as = JSON.parse(JSON.stringify(this.state.articleState));
      if (as[articleId]) { as[articleId].views = (as[articleId].views || 0) + 1; this.set({ articleState: as }); }
      API.publicArticles.view(articleId).catch(() => {});
    },

    /* ---------- admin: posts ---------- */
    async savePost(post) {
      try {
        // image_path is a server-relative path; reject absolute http URLs (they live in media_url instead).
        const localImage = post.img && !post.img.startsWith("http") ? post.img : null;
        const payload = {
          cat:        post.cat,
          titleBn:    post.bn,
          titleEn:    post.en || "",
          excerptBn:  post.excerpt_bn,
          excerptEn:  post.excerpt_en || null,
          bodyBn:     post.body || "",
          bodyEn:     post.body_en || null,
          authorBn:   post.author,
          authorEn:   post.reporter_en || "",
          imagePath:  localImage,
          mediaKind:  post.mediaKind || (localImage ? "image" : "none"),
          mediaUrl:   post.mediaUrl || null,
          mediaMime:  post.mediaMime || null,
          mediaThumbnail: post.mediaThumbnail || null,
          special:    !!post.special,
          mins:       Math.min(120, Math.max(1, Number(post.mins) || 3)),
          status:     post.status || "draft",
        };
        if (post.id) {
          await API.admin.articles.update(post.id, payload);
          this.toast("পোস্ট হালনাগাদ হয়েছে");
        } else {
          await API.admin.articles.create(payload);
          this.toast("নতুন পোস্ট তৈরি হয়েছে");
        }
        await this.refreshAdminPosts();
        await this.loadPublic();
        return true;
      } catch (e) { this.toast(apiErrMsg(e, "সংরক্ষণ ব্যর্থ")); return false; }
    },
    async deletePost(id) {
      try {
        await API.admin.articles.remove(id);
        this.toast("পোস্ট মুছে ফেলা হয়েছে");
        await this.refreshAdminPosts();
        await this.loadPublic();
        return true;
      } catch (e) { this.toast(apiErrMsg(e, "মুছে ফেলা ব্যর্থ")); return false; }
    },
    async setPostStatus(id, status) {
      try {
        await API.admin.articles.update(id, { status });
        this.toast(status === "published" ? "প্রকাশিত হয়েছে" : "খসড়ায় নেওয়া হয়েছে");
        await this.refreshAdminPosts();
        await this.loadPublic();
      } catch (e) { this.toast(apiErrMsg(e, "অবস্থা পরিবর্তন ব্যর্থ")); }
    },

    /* ---------- admin: rss queue ---------- */
    async approveQueue(id) {
      try {
        await API.admin.queue.approve(id);
        this.toast("অনুমোদিত ও প্রকাশিত");
        const [queue, posts] = await Promise.all([API.admin.queue.list(), API.admin.articles.list()]);
        this.set({ queue: (queue || []).map(mapQueueItem), posts: (posts || []).map(mapArticle) });
        await this.loadPublic();
      } catch (e) { this.toast(e.message || "অনুমোদন ব্যর্থ"); }
    },
    async rejectQueue(id) {
      try {
        await API.admin.queue.reject(id);
        this.toast("বাতিল করা হয়েছে");
        const queue = await API.admin.queue.list();
        this.set({ queue: (queue || []).map(mapQueueItem) });
      } catch (e) { this.toast(e.message || "বাতিল ব্যর্থ"); }
    },

    /* ---------- admin: feeds ---------- */
    async addFeed(feed) {
      try {
        await API.admin.feeds.create({
          name: feed.name, url: feed.url, cat: feed.cat,
          mode: feed.mode || "review", intervalMinutes: feed.interval || 15,
        });
        this.toast("ফিড যুক্ত হয়েছে");
        const feeds = await API.admin.feeds.list();
        this.set({ feeds: (feeds || []).map(mapFeed) });
      } catch (e) { this.toast(e.message || "ফিড যুক্ত করা ব্যর্থ"); }
    },
    async removeFeed(id) {
      try {
        await API.admin.feeds.remove(id);
        this.toast("ফিড সরানো হয়েছে");
        const feeds = await API.admin.feeds.list();
        this.set({ feeds: (feeds || []).map(mapFeed) });
      } catch (e) { this.toast(e.message || "সরানো ব্যর্থ"); }
    },
    async toggleFeed(id) {
      const f = this.state.feeds.find(x => x.id === id);
      if (!f) return;
      const next = f.status === "active" ? "paused" : "active";
      try {
        await API.admin.feeds.setStatus(id, next);
        const feeds = await API.admin.feeds.list();
        this.set({ feeds: (feeds || []).map(mapFeed) });
      } catch (e) { this.toast(e.message || "অবস্থা বদল ব্যর্থ"); }
    },
    async setFeedMode(id, mode) {
      try {
        await API.admin.feeds.update(id, { mode });
        const feeds = await API.admin.feeds.list();
        this.set({ feeds: (feeds || []).map(mapFeed) });
      } catch (e) { this.toast(e.message || "মোড বদল ব্যর্থ"); }
    },

    /* ---------- admin: category aliases ---------- */
    async addAlias(alias) {
      try {
        await API.admin.categoryAliases.create({ term: alias.term, cat: alias.cat, priority: alias.priority || 10 });
        this.toast("ম্যাপিং যুক্ত হয়েছে");
        const aliases = await API.admin.categoryAliases.list();
        this.set({ aliases: aliases || [] });
      } catch (e) { this.toast(e.message || "ম্যাপিং যুক্ত করা ব্যর্থ"); }
    },
    async removeAlias(term) {
      try {
        await API.admin.categoryAliases.remove(term);
        this.toast("ম্যাপিং সরানো হয়েছে");
        const aliases = await API.admin.categoryAliases.list();
        this.set({ aliases: aliases || [] });
      } catch (e) { this.toast(e.message || "সরানো ব্যর্থ"); }
    },

    /* ---------- admin: staff ---------- */
    async saveStaff(member) {
      try {
        const perms = member.perms || {};
        const payload = {
          nameBn: member.name,
          nameEn: member.en || "",
          email:  member.email,
          role:   member.role,
          cats:   member.cats || [],
          perms: {
            canCreate:      !!perms.create,
            canEdit:        !!perms.edit,
            canPublish:     !!perms.publish,
            canDelete:      !!perms.delete,
            canManageRss:   !!perms.rss,
            canManageUsers: !!perms.manageUsers,
          },
        };
        if (member.id) {
          await API.admin.staff.update(member.id, Object.assign({ status: member.status }, payload));
          this.toast("সদস্য হালনাগাদ হয়েছে");
        } else {
          await API.admin.staff.create(payload);
          this.toast("আমন্ত্রণ পাঠানো হয়েছে");
        }
        const staff = await API.admin.staff.list();
        this.set({ staff: (staff || []).map(mapStaff) });
      } catch (e) { this.toast(e.message || "সংরক্ষণ ব্যর্থ"); }
    },
    async removeStaff(id) {
      try {
        await API.admin.staff.remove(id);
        this.toast("সদস্য সরানো হয়েছে");
        const staff = await API.admin.staff.list();
        this.set({ staff: (staff || []).map(mapStaff) });
      } catch (e) { this.toast(e.message || "সরানো ব্যর্থ"); }
    },
    setCurrentStaff() { /* no-op: real auth replaces demo switcher */ },

    /* ---------- toast ---------- */
    toast(msg) {
      this.set({ toast: { msg, id: Date.now() } });
      clearTimeout(this._tt);
      this._tt = setTimeout(() => this.set({ toast: null }), 2200);
    },
  };

  function useApp() {
    React.useSyncExternalStore(
      React.useCallback(cb => AppStore.subscribe(cb), []),
      () => AppStore.state
    );
    return AppStore;
  }

  // Expose the article mapper so consumer-pages can normalise the detail DTO
  // it fetches from /api/public/articles/{id} into the same shape we use
  // everywhere else (a.bn / a.body_bn / a.mediaKind / …).
  AppStore.mapDetail = mapArticle;

  window.useApp = useApp;
  window.AppStore = AppStore;

  // Back/forward support: the hashchange listener is the single source of truth
  // for URL→state. nav()'s own hash write lands here too but matches current
  // state, so the guard makes it a no-op (no re-entrancy flag needed).
  window.addEventListener("hashchange", () => {
    const next = parseHash(location.hash);
    const cur = AppStore.state.route;
    if (next.view === cur.view && JSON.stringify(next.params) === JSON.stringify(cur.params)) return;
    AppStore.set({ route: next });
    window.scrollTo({ top: 0, behavior: "instant" in window ? "instant" : "auto" });
  });

  /* ---------- bootstrap ---------- */
  (async function boot() {
    try { document.documentElement.setAttribute("lang", AppStore.state.lang === "en" ? "en" : "bn"); } catch (_) {}
    // normalize the address bar to the canonical hash form without adding a history entry
    try { history.replaceState(null, "", "#" + routeToHash(AppStore.state.route)); } catch (_) {}
    await AppStore.loadPublic();

    if (API.getUserToken()) {
      try {
        const r = await API.me.bookmarks();
        if (r && Array.isArray(r.ids)) {
          AppStore.set({ bookmarks: r.ids });
        }
      } catch (e) {
        if (e && e.status === 401) API.setUserToken(null);
      }
    }

    if (API.getStaffToken()) {
      await AppStore.loadAdminData();
    }

    AppStore.set({ booting: false });
  })();
})();
