const pull = require('../server/node_modules/pull-stream');
const ssbRef = require('../server/node_modules/ssb-ref');
const { getConfig } = require('../configs/config-manager.js');
const { buildVoteTally } = require('../backend/vote_tally');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const logLimit = getConfig().ssbLogStream?.limit || 1000;

const safeFeedId = (v) => {
  if (typeof v === 'string' && ssbRef.isFeed(v)) return v;
  if (v && typeof v === 'object' && typeof v.link === 'string' && ssbRef.isFeed(v.link)) return v.link;
  return null;
};

const isContentSane = (c) => {
  if (!c || typeof c !== 'object') return false;
  if (c.type === 'about') {
    if (c.about === undefined) return true;
    if (typeof c.about === 'string' && ssbRef.isFeed(c.about)) return true;
    return false;
  }
  if (c.type === 'pub') {
    const addr = c.address;
    if (!addr || typeof addr !== 'object') return false;
    return typeof addr.key === 'string' && ssbRef.isFeed(addr.key);
  }
  return true;
};

const N = s => String(s || '').toUpperCase().replace(/\s+/g, '_');
const ORDER_MARKET = ['FOR_SALE','OPEN','RESERVED','CLOSED','SOLD'];
const ORDER_PROJECT = ['CANCELLED','PAUSED','ACTIVE','COMPLETED'];
const SCORE_MARKET = s => { const i = ORDER_MARKET.indexOf(N(s)); return i < 0 ? -1 : i };
const SCORE_PROJECT = s => { const i = ORDER_PROJECT.indexOf(N(s)); return i < 0 ? -1 : i };

function inferType(c = {}) {
  if (c.type === 'wallet' && c.coin === 'ECO' && typeof c.address === 'string') return 'bankWallet';
  if (c.type === 'bankClaim') return 'bankClaim';
  if (c.type === 'karmaScore') return 'karmaScore';
  if (c.type === 'courts_case') return 'courtsCase';
  if (c.type === 'courts_evidence') return 'courtsEvidence';
  if (c.type === 'courts_answer') return 'courtsAnswer';
  if (c.type === 'courts_verdict') return 'courtsVerdict';
  if (c.type === 'courts_settlement') return 'courtsSettlement';
  if (c.type === 'courts_nomination') return 'courtsNomination';
  if (c.type === 'courts_nom_vote') return 'courtsNominationVote';
  if (c.type === 'courts_public_pref') return 'courtsPublicPref';
  if (c.type === 'courts_mediators') return 'courtsMediators';
  if (c.type === 'map') return 'map';
  if (c.type === 'mapMarker') return 'mapMarker';
  if (c.type === 'chat') return 'chat';
  if (c.type === 'chatMessage') return 'chatMessage';
  if (c.type === 'vote' && c.vote && typeof c.vote.link === 'string') {
    const br = Array.isArray(c.branch) ? c.branch : [];
    if (br.includes(c.vote.link) && Number(c.vote.value) === 1) return 'spread';
  }
  return c.type || '';
}

const HIDDEN_ENVELOPE_TYPES = new Set([
  'tribe-keys-distrib',
  'tribe-keys',
  'tribe-invite-msg',
  'tribe-invite-tombstone',
  'aiExchangeVote',
  'event-invite',
  'forum-invite',
  'larpJoinHouse',
  'larpLeaveLarp',
  'larpTestAttempt',
  'larpHousePost',
  'larpHouseInvite',
  'larpHouseInviteRedeem',
  'larpHouseTribeAnchor',
  'larpHouseTribeAnchorTombstone',
  'larpAutoInvite',
  'karmaScore',
  'pubAvailability',
  'calendarReminderSent',
  'taskReminderSent',
  'ubiClaimResult',
  'ubiAllocation',
  'courts-key'
]);

module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }) => {
  let ssb;
  const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb };

  let _feedCache = null;
  let _feedCacheInflight = null;
  const FEED_CACHE_MS = 15 * 1000;

  const buildAccessibleTribeIds = async () => {
    const set = new Set();
    if (!tribesModel) return set;
    try {
      const list = await tribesModel.listAll();
      for (const t of list) {
        if (!t || !t.id) continue;
        set.add(t.id);
        try {
          const chain = await tribesModel.getChainIds(t.id);
          for (const cid of chain) set.add(cid);
        } catch (_) {}
      }
    } catch (_) {}
    return set;
  };

  const tryDecryptPublicInviteKey = (invites) => {
    if (!tribeCrypto || !Array.isArray(invites)) return null;
    for (const inv of invites) {
      if (!inv || typeof inv !== 'object' || inv.public !== true) continue;
      if (typeof inv.code !== 'string') continue;
      if (typeof inv.ek === 'string') {
        try {
          const k = tribeCrypto.decryptFromInvite(inv.ek, inv.code, inv.salt);
          if (k) return k;
        } catch (_) {}
      }
      if (typeof inv.ekChain === 'string') {
        try {
          const chain = tribeCrypto.decryptChainFromInvite(inv.ekChain, inv.code, inv.salt);
          if (Array.isArray(chain) && chain.length && chain[0].key) return chain[0].key;
        } catch (_) {}
      }
    }
    return null;
  };
  const hasBlob = async (ssbClient, url) => new Promise(resolve => ssbClient.blobs.has(url, (err, has) => resolve(!err && has)));
  const getMsg = async (ssbClient, key) => new Promise(resolve => ssbClient.get(key, (err, msg) => resolve(err ? null : msg)));
  const normNL = (s) => String(s || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
  const stripHtml = (s) => normNL(s)
    .replace(/<br\s*\/?>/gi, '\n')
    .replace(/<\/p\s*>/gi, '\n\n')
    .replace(/<[^>]*>/g, '')
    .replace(/[ \t]+\n/g, '\n')
    .replace(/\n{3,}/g, '\n\n')
    .trim();
  const excerpt = (s, max = 900) => {
    const t = stripHtml(s);
    if (!t) return '';
    return t.length > max ? t.slice(0, max - 1) + '…' : t;
  };

  // Builds self-contained chatThread items (title + recent messages embedded) for
  // general (non-tribe) OPEN chats that have activity. Emitted as one item per chat
  // with ts = latest message, so it survives the recent/all/mine filters intact.
  const CHAT_THREAD_LIMIT = 6;
  const buildChatThreads = async (ssbClient, idToAction, rootOf) => {
    const replacedChatIds = new Set();
    for (const a of idToAction.values()) {
      if (a.type !== 'chat') continue;
      const rep = a.content && a.content.replaces;
      if (rep) replacedChatIds.add(rep);
    }
    const stateByRoot = new Map();
    for (const a of idToAction.values()) {
      if (a.type !== 'chat') continue;
      const root = rootOf(a.id);
      const isTip = !replacedChatIds.has(a.id);
      const prev = stateByRoot.get(root);
      // Resolve the chain tip by replaces (a non-replaced action wins), breaking ts
      // ties correctly — the mock and real SSB can stamp equal timestamps.
      if (prev && !((isTip && !prev.isTip) || (isTip === prev.isTip && (a.ts || 0) >= prev.ts))) continue;
      const cc = a.content || {};
      stateByRoot.set(root, { ts: a.ts || 0, isTip, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || '') });
    }
    const msgsByRoot = new Map();
    for (const a of idToAction.values()) {
      if (a.type !== 'chatMessage') continue;
      const c = a.content || {};
      if (c.tribeId || c.encryptedText) continue;
      const root = c.chatId;
      if (!root || typeof c.text !== 'string' || !c.text) continue;
      if (!msgsByRoot.has(root)) msgsByRoot.set(root, []);
      msgsByRoot.get(root).push(a);
    }
    for (const root of msgsByRoot.keys()) {
      if (stateByRoot.has(root)) continue;
      const val = await getMsg(ssbClient, root);
      const cc = val && val.content;
      if (cc && typeof cc === 'object' && cc.type === 'chat') {
        stateByRoot.set(root, { ts: 0, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || '') });
      } else {
        stateByRoot.set(root, null);
      }
    }
    const items = [];
    for (const [root, msgs] of msgsByRoot.entries()) {
      const info = stateByRoot.get(root);
      if (!info || info.tribeId || info.status !== 'OPEN') continue;
      const asc = msgs.slice().sort((x, y) => (x.ts || 0) - (y.ts || 0));
      const last = asc[asc.length - 1];
      items.push({
        id: `chatthread:${root}`,
        type: 'chatThread',
        author: last.author,
        ts: last.ts || 0,
        content: {
          type: 'chatThread',
          chatRoot: root,
          title: info.title || '',
          description: info.description || '',
          replies: asc.slice(-CHAT_THREAD_LIMIT).map(m => ({ id: m.id, author: m.author, ts: m.ts || 0, text: (m.content && m.content.text) || '' }))
        }
      });
    }
    return items;
  };

  return {
    invalidateCache() {
      _feedCache = null;
      _feedCacheInflight = null;
    },
    async listFeed(filter = 'all') {
      const cacheKey = filter || 'all';
      const now = Date.now();
      if (!_feedCache) _feedCache = new Map();
      const entry = _feedCache.get(cacheKey);
      if (entry && now - entry.ts < FEED_CACHE_MS) return entry.value;
      if (!_feedCacheInflight) _feedCacheInflight = new Map();
      if (_feedCacheInflight.has(cacheKey)) return _feedCacheInflight.get(cacheKey);
      const promise = (async () => {
      const ssbClient = await openSsb();
      const userId = ssbClient.id;

      const results = await new Promise((resolve, reject) => {
        pull(
          ssbClient.createLogStream({ reverse: true, limit: logLimit }),
          pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
        );
      });

      if (typeof ssbClient.createUserStream === 'function') {
        const ownResults = await new Promise((resolve) => {
          pull(
            ssbClient.createUserStream({ id: userId, reverse: true }),
            pull.collect((err, msgs) => resolve(err ? [] : msgs))
          );
        });
        const seenKeys = new Set(results.map(m => m && m.key));
        for (const m of ownResults) {
          if (m && m.key && !seenKeys.has(m.key)) results.push(m);
        }
      }

      const tombstoned = buildValidatedTombstoneSet(results);
      const parentOf = new Map();
      const idToAction = new Map();
      const rawById = new Map();
      const voteTally = buildVoteTally(results);
      const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null;
      const accessibleTribeIds = await buildAccessibleTribeIds();

      for (const msg of results) {
        const k = msg.key;
        const v = msg.value;
        let c = v?.content;
        if (!c) continue;
        if (typeof c === 'string' && c.endsWith('.box')) continue;
        if (c.type && HIDDEN_ENVELOPE_TYPES.has(c.type)) continue;
        if (typeof c.type === 'string' && /e2ee/i.test(c.type)) continue;
        if (c.type === 'contact') continue;
        if (tribeCrypto && tribeCrypto.isTribeMsg(c)) {
          const r = fpIdx ? tribeCrypto.unwrapMsg(c, fpIdx) : null;
          if (!r || !r.body) continue;
          const inner = r.body;
          if (inner.k !== 'tribe' || inner.op !== 'create') continue;
          c = { ...inner, type: 'tribe', _decrypted: true, _rootId: r.rootId };
        } else if (c.tribeId && !accessibleTribeIds.has(c.tribeId)) {
          continue;
        }
        if (!c.type) continue;
        if (c.type === 'tombstone') continue;
        if (c.encryptedPayload && tribeCrypto && !c.tribeId) {
          const pubKey = tryDecryptPublicInviteKey(c.invites);
          if (pubKey) {
            try {
              const dec = tribeCrypto.decryptContent(c, [[pubKey]]);
              if (dec && !dec._undecryptable) c = dec;
            } catch (_) {}
          }
        }
        if (c.type === 'pad' && c.encrypted === true && !c.tribeId && padsModel && typeof padsModel.decryptContentPublicSync === 'function') {
          try {
            const dec = padsModel.decryptContentPublicSync(c);
            if (dec) c = { ...c, title: dec.title, deadline: dec.deadline, tags: dec.tags, encrypted: false };
          } catch (_) {}
        }
        if (!isContentSane(c)) continue;
        const ts = v?.timestamp || Number(c?.timestamp || 0) || (c?.updatedAt ? Date.parse(c.updatedAt) : 0) || 0;
        idToAction.set(k, { id: k, author: v?.author, ts, type: inferType(c), content: c });
        rawById.set(k, msg);
        if (c.replaces) parentOf.set(k, c.replaces);
      }

      const forgedParentOf = new Map();
      const forgedFeedContent = new Map();
      for (const [child, parent] of Array.from(parentOf.entries())) {
        const ca = idToAction.get(child);
        const pa = idToAction.get(parent);
        if (!pa) { parentOf.delete(child); continue; }
        if (!ca || String(ca.author) !== String(pa.author)) {
          forgedParentOf.set(child, parent);
          if (ca && ca.type === 'feed') forgedFeedContent.set(child, ca.content || {});
          parentOf.delete(child);
          idToAction.delete(child);
          rawById.delete(child);
        }
      }

      const replacedIds = new Set(parentOf.values());
      const spreadVoteState = new Map();
      const aiHelpfulCounts = new Map();
      const aiHelpfulSeen = new Map();
      for (const msg of results) {
        const v = msg.value;
        const c = v && v.content;
        if (!c || c.type !== 'aiExchangeVote') continue;
        const target = String(c.target || '');
        const author = v.author;
        if (!target || !author) continue;
        const key = `${target}\u0000${author}`;
        const prev = aiHelpfulSeen.get(key);
        const curTs = v.timestamp || 0;
        if (!prev || curTs > prev.ts) aiHelpfulSeen.set(key, { target, ts: curTs, helpful: c.helpful !== false });
      }
      for (const { target, helpful } of aiHelpfulSeen.values()) {
        if (!helpful) continue;
        aiHelpfulCounts.set(target, (aiHelpfulCounts.get(target) || 0) + 1);
      }

      for (const a of idToAction.values()) {
        const c = a.content || {};
        if (c.type !== 'vote' || !c.vote || typeof c.vote.link !== 'string') continue;

        const link = c.vote.link;
        const br = Array.isArray(c.branch) ? c.branch : [];
        if (!br.includes(link)) continue;

        if (tombstoned.has(a.id)) continue;
        if (replacedIds.has(a.id)) continue;
        if (tombstoned.has(link)) continue;

        const author = a.author;
        if (!author) continue;

        const value = Number(c.vote.value);
        const key = `${link}:${author}`;
        const prev = spreadVoteState.get(key);
        const curTs = a.ts || 0;

        if (!prev || curTs > prev.ts || (curTs === prev.ts && String(a.id || '').localeCompare(String(prev.id || '')) > 0)) {
          spreadVoteState.set(key, { ts: curTs, id: a.id, value, link });
        }
      }

      const spreadCountByTarget = new Map();
      for (const v of spreadVoteState.values()) {
        if (Number(v.value) !== 1) continue;
        spreadCountByTarget.set(v.link, (spreadCountByTarget.get(v.link) || 0) + 1);
      }

      const fetchedTargetCache = new Map();

      for (const a of idToAction.values()) {
        if (a.type !== 'spread') continue;
        const c = a.content || {};
        const link = c.vote?.link || '';
        const totalSpreads = link ? (spreadCountByTarget.get(link) || 0) : 0;

        let targetMsg = link ? rawById.get(link) : null;
        if (!targetMsg && link) {
          if (fetchedTargetCache.has(link)) targetMsg = fetchedTargetCache.get(link);
          else {
            const got = await getMsg(ssbClient, link);
            if (got) {
              const wrapped = { key: link, value: got };
              fetchedTargetCache.set(link, wrapped);
              targetMsg = wrapped;
            } else {
              fetchedTargetCache.set(link, null);
              targetMsg = null;
            }
          }
        }

        const targetContent = targetMsg?.value?.content || null;
        const title =
          (typeof targetContent?.title === 'string' && targetContent.title.trim())
            ? targetContent.title.trim()
            : (typeof targetContent?.name === 'string' && targetContent.name.trim())
              ? targetContent.name.trim()
              : '';
        const rawText =
          (typeof targetContent?.text === 'string' && targetContent.text.trim())
            ? targetContent.text
            : (typeof targetContent?.description === 'string' && targetContent.description.trim())
              ? targetContent.description
              : '';
        const text = rawText ? excerpt(rawText, 700) : '';
        const cw =
          (typeof targetContent?.contentWarning === 'string' && targetContent.contentWarning.trim())
            ? targetContent.contentWarning
            : '';
        a.content = {
          ...c,
          spreadTargetId: link,
          spreadTotalSpreads: totalSpreads,
          spreadOriginalAuthor: targetMsg?.value?.author || '',
          spreadTitle: title,
          spreadContentWarning: cw,
          spreadText: text
        };
      }

      const rootOf = (id) => { let cur = id; while (parentOf.has(cur)) cur = parentOf.get(cur); return cur };

      const groups = new Map();
      for (const [id, action] of idToAction.entries()) {
        const root = rootOf(id);
        if (!groups.has(root)) groups.set(root, []);
        groups.get(root).push(action);
      }

      const idToTipId = new Map();

      for (const [root, arr] of groups.entries()) {
        if (!arr.length) continue;
        const type = arr[0].type;

        if (type !== 'project') {
          const tip = arr.reduce((best, a) => (a.ts > best.ts ? a : best), arr[0]);
          for (const a of arr) idToTipId.set(a.id, tip.id);

          if (type === 'task' && tip && tip.content && tip.content.isPublic !== 'PRIVATE') {
            const uniq = (xs) => Array.from(new Set((Array.isArray(xs) ? xs : []).filter(x => typeof x === 'string' && x.trim().length)));
            const sorted = arr
              .filter(a => a.type === 'task' && a.content && typeof a.content === 'object')
              .sort((a, b) => (a.ts || 0) - (b.ts || 0));

            let prev = null;

            for (const ev of sorted) {
              const cur = uniq(ev.content.assignees);
              if (prev) {
                const prevSet = new Set(prev);
                const curSet = new Set(cur);
                const added = cur.filter(x => !prevSet.has(x));
                const removed = prev.filter(x => !curSet.has(x));

                if (added.length || removed.length) {
                  const overlayId = `${ev.id}:assignees:${added.join(',')}:${removed.join(',')}`;
                  idToAction.set(overlayId, {
                    id: overlayId,
                    author: ev.author,
                    ts: ev.ts,
                    type: 'taskAssignment',
                    content: {
                      taskId: tip.id,
                      title: tip.content.title || ev.content.title || '',
                      added,
                      removed,
                      isPublic: tip.content.isPublic
                    }
                  });
                  idToTipId.set(overlayId, overlayId);
                }
              }
              prev = cur;
            }
          }

          continue;
        }

        let tip = arr[0];
        let bestScore = SCORE_PROJECT(tip.content.status);
        for (const a of arr) {
          const s = SCORE_PROJECT(a.content.status);
          if (s > bestScore || (s === bestScore && a.ts > tip.ts)) { tip = a; bestScore = s }
        }
        for (const a of arr) idToTipId.set(a.id, tip.id);

        const baseTitle = (tip.content && tip.content.title) || '';
        const overlays = arr
          .filter(a => a.type === 'project' && (a.content.followersOp || a.content.backerPledge))
          .sort((a, b) => (a.ts || 0) - (b.ts || 0));

        for (const ev of overlays) {
          if (tombstoned.has(ev.id)) continue;

          let kind = null;
          let amount = null;

          if (ev.content.followersOp === 'follow') kind = 'follow';
          else if (ev.content.followersOp === 'unfollow') kind = 'unfollow';
          if (ev.content.backerPledge && typeof ev.content.backerPledge.amount !== 'undefined') {
            const amt = Math.max(0, parseFloat(ev.content.backerPledge.amount || 0) || 0);
            if (amt > 0) { kind = kind || 'pledge'; amount = amt }
          }
          if (!kind) continue;
          const augmented = {
            ...ev,
            type: 'project',
            content: {
              ...ev.content,
              title: baseTitle,
              projectId: tip.id,
              activity: { kind, amount },
              activityActor: ev.author
            }
          };
          idToAction.set(ev.id, augmented);
          idToTipId.set(ev.id, ev.id);
        }
      }

      const feedOpinionsByRoot = new Map();
      for (const msg of results) {
        const v = msg && msg.value;
        const c = v && v.content;
        if (!c || typeof c !== 'object') continue;
        const isOpinion = c.type === 'feedOpinion' && typeof c.target === 'string';
        const isVoteAction = c.type === 'feed-action' && c.action === 'vote' && (typeof c.root === 'string' || typeof c.target === 'string');
        if (!isOpinion && !isVoteAction) continue;
        const target = isOpinion ? c.target : String(c.root || c.target);
        let cur = target;
        let g = 0;
        while (g++ < 1000) {
          if (forgedParentOf.has(cur)) { cur = forgedParentOf.get(cur); continue; }
          if (parentOf.has(cur)) { cur = parentOf.get(cur); continue; }
          break;
        }
        const r = cur;
        if (!feedOpinionsByRoot.has(r)) feedOpinionsByRoot.set(r, []);
        feedOpinionsByRoot.get(r).push({ author: v.author, category: String(c.category || '') });
      }

      const forgedFeedAggByRoot = new Map();
      for (const [child, fc] of forgedFeedContent.entries()) {
        let cur = child;
        let g = 0;
        while (g++ < 1000) {
          if (forgedParentOf.has(cur)) { cur = forgedParentOf.get(cur); continue; }
          if (parentOf.has(cur)) { cur = parentOf.get(cur); continue; }
          break;
        }
        if (!forgedFeedAggByRoot.has(cur)) forgedFeedAggByRoot.set(cur, []);
        forgedFeedAggByRoot.get(cur).push(fc);
      }

      const latest = [];
      for (const a of idToAction.values()) {
        if (tombstoned.has(a.id)) continue;
        if (a.type === 'tribe' && parentOf.has(a.id)) continue;
        const c = a.content || {};
        if (c.root && tombstoned.has(c.root) && !c.replaces) continue;
        if (a.type === 'vote' && tombstoned.has(c.vote?.link)) continue;
        if (a.type === 'spread' && (c.spreadTargetId || c.vote?.link) && tombstoned.has(c.spreadTargetId || c.vote?.link)) continue;
        if (c.key && tombstoned.has(c.key)) continue;
        if (c.branch && tombstoned.has(c.branch)) continue;
        if (c.target && tombstoned.has(c.target)) continue;
        if (a.type === 'document') {
          const url = c.url;
          const ok = await hasBlob(ssbClient, url);
          if (!ok) continue;
        }
        if (a.type === 'forum' && c.root) {
          const rootId = typeof c.root === 'string' ? c.root : (c.root?.key || c.root?.id || '');
          const rootAction = idToAction.get(rootId);
          a.content.rootTitle = rootAction?.content?.title || a.content.rootTitle || '';
          a.content.rootKey = rootId || a.content.rootKey || '';
        }
        const actionRoot = rootOf(a.id);
        const extra = a.type === 'aiExchange' ? { helpfulVotes: aiHelpfulCounts.get(a.id) || 0 } : {};
        const voteAgg = a.type === 'votes' ? voteTally.get(a.id) : null;
        let content = voteAgg ? { ...c, ...voteAgg } : a.content;
        if (a.type === 'feed') {
          const base = a.content || {};
          const opinions = { ...(base.opinions || {}) };
          const voters = Array.isArray(base.opinions_inhabitants) ? base.opinions_inhabitants.slice() : [];
          const voterSet = new Set(voters);
          for (const fc of (forgedFeedAggByRoot.get(actionRoot) || [])) {
            for (const [cat, n] of Object.entries(fc.opinions || {})) {
              opinions[cat] = (Number(opinions[cat]) || 0) + (Number(n) || 0);
            }
            for (const vt of (Array.isArray(fc.opinions_inhabitants) ? fc.opinions_inhabitants : [])) {
              if (voterSet.has(vt)) continue;
              voterSet.add(vt);
              voters.push(vt);
            }
          }
          for (const op of (feedOpinionsByRoot.get(actionRoot) || [])) {
            if (!op.author || voterSet.has(op.author)) continue;
            voterSet.add(op.author);
            voters.push(op.author);
            if (op.category) opinions[op.category] = (Number(opinions[op.category]) || 0) + 1;
          }
          content = { ...content, opinions, opinions_inhabitants: voters };
        }
        latest.push({ ...a, content, ...extra, tipId: idToTipId.get(a.id) || a.id, rootId: actionRoot !== a.id ? actionRoot : null });
      }

      let deduped = latest.filter(a => !a.tipId || a.tipId === a.id || (a.type === 'tribe' && !parentOf.has(a.id)));

      const mediaTypes = new Set(['image','video','audio','document','bookmark','map']);
      const perAuthorUnique = new Set();
      const byKey = new Map();
      const norm = s => String(s || '').trim().toLowerCase();

      for (const a of deduped) {
        const c = a.content || {};
        const effTs =
          (c.updatedAt && Date.parse(c.updatedAt)) ||
          (c.createdAt && Date.parse(c.createdAt)) ||
          (a.ts || 0);

        if (mediaTypes.has(a.type)) {
          const u = c.url || c.title || `${a.type}:${a.id}`;
          const key = `${a.type}:${u}`;
          const prev = byKey.get(key);
          if (!prev || effTs > prev.__effTs) byKey.set(key, { ...a, __effTs: effTs });
        } else if (perAuthorUnique.has(a.type)) {
          const key = `${a.type}:${a.author}`;
          const prev = byKey.get(key);
          if (!prev || effTs > prev.__effTs) byKey.set(key, { ...a, __effTs: effTs });
        } else if (a.type === 'about') {
          const target = c.about || a.author;
          if (String(target) !== String(a.author)) {
            byKey.set(`id:${a.id}`, { ...a, __effTs: effTs });
            continue;
          }
          const key = `about:${target}`;
          const prev = byKey.get(key);
          const prevContent = prev && (prev.content || {});
          const prevHasImage = !!(prevContent && prevContent.image);
          const newHasImage = !!c.image;

          if (!prev) {
            byKey.set(key, { ...a, __effTs: effTs, __hasImage: newHasImage });
          } else if (!prevHasImage && newHasImage) {
            byKey.set(key, { ...a, __effTs: effTs, __hasImage: newHasImage });
          } else if (prevHasImage === newHasImage && effTs > prev.__effTs) {
            byKey.set(key, { ...a, __effTs: effTs, __hasImage: newHasImage });
          }
        } else if (a.type === 'tribe') {
          const t = norm(c.title);
          if (t) {
            const key = `tribe:${t}::${a.author}`;
            const prev = byKey.get(key);
            if (!prev || effTs > prev.__effTs) byKey.set(key, { ...a, __effTs: effTs });
          } else {
            const key = `id:${a.id}`;
            byKey.set(key, { ...a, __effTs: effTs });
          }
        } else {
          const key = `id:${a.id}`;
          byKey.set(key, { ...a, __effTs: effTs });
        }
      }

      deduped = Array.from(byKey.values()).map(x => { delete x.__effTs; delete x.__hasImage; return x });

      if (industryModel && deduped.some(a => a.type === 'industryBuild')) {
        const builds = await industryModel.listAllBuilds().catch(() => []);
        const buildStatusById = new Map();
        for (const b of (builds || [])) buildStatusById.set(b.id || b.key, String(b.status || 'PROPOSED').toUpperCase());
        deduped = deduped.filter(a => {
          if (a.type !== 'industryBuild') return true;
          const st = buildStatusById.get(a.id);
          if (st === undefined || st === 'PROPOSED' || st === 'REJECTED') return false;
          a.content = { ...a.content, buildStatus: st };
          return true;
        });
      }

      if (industryModel && deduped.some(a => a.type === 'industryBlueprint')) {
        const bps = await industryModel.listAllBlueprints().catch(() => []);
        const bpById = new Map((bps || []).map(b => [b.id || b.key, b]));
        for (const a of deduped) {
          if (a.type !== 'industryBlueprint') continue;
          const b = bpById.get(a.id);
          if (b && b.estTotal != null) a.content = { ...a.content, estTotal: b.estTotal };
        }
      }

      const tribeInternalTypes = new Set(['tribe-content', 'tribeParliamentCandidature', 'tribeParliamentTerm', 'tribeParliamentProposal', 'tribeParliamentRule', 'tribeParliamentLaw', 'tribeParliamentRevocation']);
      const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'taskReminderSent', 'feed-action', 'pubBalance', 'pubAvailability', 'log', 'gameScore']);
      const chatThreadItems = await buildChatThreads(ssbClient, idToAction, rootOf, deduped);
      deduped = deduped.concat(chatThreadItems);
      const isAllowedTribeActivity = (a) => {
        if (tribeInternalTypes.has(a.type)) return false;
        const c = a.content || {};
        if (c.tribeId) return false;
        if (a.type === 'tribe') {
          const isInitial = !c.replaces;
          if (!isInitial) return false;
          if (c.isAnonymous === true && !c._decrypted) return false;
          const st = String(c.status || '').toUpperCase();
          if ((st === 'PRIVATE' || st === 'INVITE-ONLY') && !(Array.isArray(c.members) && c.members.includes(userId))) return false;
        }
        return true;
      };
      const itemVisible = (a) => {
        if (hiddenTypes.has(a.type)) return false;
        const c = a.content || {};
        if (c.encryptedPayload) return false;
        if (a.type === 'pad' && c.status !== 'OPEN') return false;
        if (a.type === 'chat' && c.status !== 'OPEN') return false;
        if (a.type === 'calendar' && c.status !== 'OPEN') return false;
        if (a.type === 'event' && String(c.isPublic || '').toLowerCase() === 'private' && c.organizer !== userId && !(Array.isArray(c.attendees) && c.attendees.includes(userId))) return false;
        if (a.type === 'task' && String(c.isPublic || '').toUpperCase() === 'PRIVATE' && a.author !== userId && !(Array.isArray(c.assignees) && c.assignees.includes(userId))) return false;
        if (a.type === 'forum' && c.isPrivate === true && a.author !== userId) return false;
        if (a.type === 'job' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId && !(Array.isArray(c.subscribers) && c.subscribers.includes(userId))) return false;
        if (a.type === 'market' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && c.seller !== userId) return false;
        if (a.type === 'shop' && String(c.visibility || '').toUpperCase() === 'CLOSED' && a.author !== userId) return false;
        if (a.type === 'curriculum' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId) return false;
        if (a.type === 'shopProduct' && c.shopVisibility && String(c.shopVisibility).toUpperCase() === 'CLOSED' && a.author !== userId) return false;
        if (a.type === 'transfer' && String(c.status || '').toUpperCase() === 'UNCONFIRMED' && a.author !== userId && c.from !== userId && c.to !== userId) return false;
        return true;
      };
      const isVisible = (a) => {
        if (!itemVisible(a)) return false;
        if (a.type === 'post' || a.type === 'opinion') {
          const c = a.content || {};
          const ref = (typeof c.root === 'string' && c.root)
            || (typeof c.branch === 'string' && c.branch)
            || (typeof c.target === 'string' && c.target)
            || null;
          if (ref) {
            const parent = idToAction.get(ref);
            if (parent && !itemVisible(parent)) return false;
          }
        }
        return true;
      };

      let out;
      if (filter === 'mine') out = deduped.filter(a => a.author === userId && isAllowedTribeActivity(a) && isVisible(a));
      else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff && isAllowedTribeActivity(a) && isVisible(a)) }
      else if (filter === 'all') out = deduped.filter(a => isAllowedTribeActivity(a) && isVisible(a));
      else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim' || a.type === 'ubiClaim');
      else if (filter === 'tribe') out = deduped.filter(a => a.type === 'tribe' || String(a.type || '').startsWith('tribe'));
      else if (filter === 'spread') out = deduped.filter(a => a.type === 'spread');
      else if (filter === 'parliament')
        out = deduped.filter(a =>
          ['parliamentCandidature','parliamentTerm','parliamentProposal','parliamentRevocation','parliamentLaw'].includes(a.type)
        );
      else if (filter === 'courts')
        out = deduped.filter(a => {
          const t = String(a.type || '').toLowerCase();
          return t === 'courtscase' || t === 'courtsnomination' || t === 'courtsnominationvote';
        });
      else if (filter === 'task')
        out = deduped.filter(a => a.type === 'task' || a.type === 'taskAssignment');
      else if (filter === 'industry')
        out = deduped.filter(a => ['industry', 'industryBuild', 'industryBlueprint', 'industryAllocation'].includes(a.type) && isVisible(a));
      else if (filter === 'pad') out = deduped.filter(a => a.type === 'pad' && (a.content || {}).status === 'OPEN');
      else if (filter === 'chat') out = deduped.filter(a => (a.type === 'chat' || a.type === 'chatThread') && isAllowedTribeActivity(a) && isVisible(a));
      else if (filter === 'calendar') out = deduped.filter(a => a.type === 'calendar' && (a.content || {}).status === 'OPEN');
      else if (filter === 'transfer') out = deduped.filter(a => a.type === 'transfer' && isVisible(a));
      else out = deduped.filter(a => a.type === filter);

      out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
      return out;
      })();
      _feedCacheInflight.set(cacheKey, promise);
      try {
        const value = await promise;
        _feedCache.set(cacheKey, { value, ts: Date.now() });
        return value;
      } finally {
        _feedCacheInflight.delete(cacheKey);
      }
    }
  };
};

