(function (root, factory) {
  const exports = factory();
  if (typeof module === 'object' && module.exports) {
    module.exports = exports.createFollowing;
    module.exports.attachFollowing = exports.attachFollowing;
  } else {
    root.createFollowing = exports.createFollowing;
    root.attachFollowing = exports.attachFollowing;
  }
})(typeof self !== 'undefined' ? self : this, function () {
  function createFollowing(options) {
    let generation = 0;
    let snapshot = {revision:0, items:[]};
    let review = null;

    function sameItem(left, right) {
      return (left?.kind || 'issue') === (right?.kind || 'issue') &&
        left?.repository === right?.repository && Number(left?.number) === Number(right?.number);
    }

    function publish(status, error) {
      const state = {status, revision:snapshot.revision, items:[...snapshot.items]};
      state.degraded = snapshot.degraded === true;
      state.refreshFailures = Number(snapshot.refreshFailures) || 0;
      if (review?.acknowledged.size) state.reviewSummary = {
          reviewed:review.acknowledged.size,
          remaining:snapshot.items.filter(item => item.has_unseen_change === true).length,
      };
      if (error) state.error = error;
      options.render?.(state);
      if (status === 'ready') options.onCount?.(
        snapshot.items.filter(item => item.has_unseen_change === true).length);
      return state;
    }

    async function load() {
      const requestGeneration = ++generation;
      publish('loading');
      try {
        const result = await options.fetchJson('api/v1/following', {headers:{Accept:'application/json'}});
        if (requestGeneration !== generation) return snapshot;
        snapshot = {
          revision:Number(result?.revision) || 0,
          items:Array.isArray(result?.items) ? result.items.slice(0, 50)
            .map(item => ({...item, kind:item.kind === 'pull' ? 'pull' : 'issue'})) : [],
          degraded:result?.degraded === true,
          refreshFailures:Number(result?.refresh_failures) || 0,
        };
        publish('ready');
        return snapshot;
      } catch (error) {
        if (requestGeneration === generation) publish('error', error);
        throw error;
      }
    }

    async function acknowledge(item) {
      const current = snapshot.items.find(candidate => sameItem(candidate, item) &&
        candidate.updated_at === item?.updated_at);
      if (!current || current.has_unseen_change !== true || typeof options.onAcknowledge !== 'function') return false;
      await options.onAcknowledge(current);
      current.has_unseen_change = false;
      review?.acknowledged.add(current.kind + ':' + current.repository + '#' + current.number + '@' + current.updated_at);
      publish('ready');
      return true;
    }

    async function keepForLater(item) {
      const exact = candidate => sameItem(candidate, item) && candidate.updated_at === item?.updated_at;
      const current = snapshot.items.find(exact);
      const index = review?.active ? review.items.findIndex(exact) : -1;
      if (!current || index < 0 || typeof options.onKeep !== 'function') return null;
      await options.onKeep(current);
      current.has_unseen_change = true;
      review.acknowledged.delete(current.kind + ':' + current.repository + '#' +
        current.number + '@' + current.updated_at);
      review.items.splice(index, 1);
      const next = review.items[index] || null;
      if (!next) review.active = false;
      publish('ready');
      if (next) await open(snapshot.items.findIndex(candidate => sameItem(candidate, next)));
      return next;
    }

    async function open(index) {
      const item = snapshot.items[Number(index)];
      if (!item) return false;
      await options.onOpen?.({...item, following:true});
      await acknowledge(item);
      return true;
    }

    async function startReview() {
      const items = snapshot.items
        .filter(item => item.has_unseen_change === true)
        .map(item => ({...item, following:true}));
      if (!items.length) return false;
      review = {items, more:false, active:true, acknowledged:new Set()};
      await open(snapshot.items.indexOf(snapshot.items.find(item => sameItem(item, items[0]))));
      return true;
    }

    function finishReview() {
      const completed = Boolean(review?.completed || (review?.active &&
        !snapshot.items.some(item => item.has_unseen_change === true)));
      if (review) review.active = false;
      if (completed && !review.completed) {
        review.completed = true;
        options.onReviewComplete?.();
      }
      publish('ready');
      return completed;
    }

    function retire(item) {
      const same = candidate => sameItem(candidate, item);
      const index = review?.active ? review.items.findIndex(same) : -1;
      snapshot.items = snapshot.items.filter(candidate => !same(candidate));
      if (index < 0) {
        publish('ready');
        return null;
      }
      review.items.splice(index, 1);
      const next = review.items[index] || null;
      if (!next) {
        review.active = false;
        if (!review.completed) {
          review.completed = true;
          options.onReviewComplete?.();
        }
      }
      publish('ready');
      return next ? {...next} : null;
    }

    return {
      load, open, startReview, previewLoaded:acknowledge, keepForLater, finishReview, retire,
      session:() => review?.active ? {items:[...review.items], more:false} : null,
      items:() => snapshot.items.map(item => ({...item})),
      count:() => snapshot.items.length,
    };
  }

  function attachFollowing(onOpen, hooks = {}) {
    const document = globalThis.document;
    const query = selector => document.querySelector(selector);
    const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character =>
      ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[character]);
    const formatTime = value => new Date(value).toLocaleString();
    const fetchJson = async (url, options) => {
      const response = await fetch(url, options);
      const payload = await response.json().catch(() => ({}));
      if (!response.ok) throw new Error(payload.detail || payload.error || 'Following is temporarily unavailable.');
      return payload;
    };
    const changeRevision = (item, action) => {
      const [owner, repo] = item.repository.split('/');
      return fetchJson('api/v1/following/' + encodeURIComponent(owner) + '/' +
        encodeURIComponent(repo) + '/issues/' + item.number + '/' + action +
        '?kind=' + encodeURIComponent(item.kind), {
          method:'PUT', headers:{'Content-Type':'application/json', Accept:'application/json'},
          body:JSON.stringify({updated_at:item.updated_at}),
        });
    };
    let feature;
    let activeReviewItem = null;
    const show = () => query('#following-sheet').open || query('#following-sheet').showModal();
    function render(state) {
      hooks.onStatus?.(state.status);
      const list = query('#following-list');
      const status = query('#following-status');
      const reviewButton = query('#review-following');
      query('#retry-following').hidden = state.status !== 'error';
      if (state.status === 'loading') return void (status.textContent = 'Loading watched items…');
      if (state.status === 'error') return void (status.textContent = state.error?.message || 'Following is temporarily unavailable.');
      const unseen = state.items.filter(item => item.has_unseen_change === true).length;
      reviewButton.hidden = unseen === 0;
      reviewButton.textContent = unseen === 1 ? 'Review new activity' : 'Review ' + unseen + ' new changes';
      status.textContent = (state.reviewSummary
        ? 'Reviewed ' + state.reviewSummary.reviewed + ' changes · ' + state.reviewSummary.remaining + ' still need review. '
        : '') + (state.degraded ? 'Some watched items could not be refreshed. Showing last known details. ' : '') + (state.items.length
        ? state.items.length + (state.items.length === 1 ? ' watched item.' : ' watched items.')
        : 'No watched items yet. Watch an issue or pull request from Search to keep it here.');
      list.innerHTML = state.items.map((item, index) =>
        '<button class="following-card' + (item.has_unseen_change ? ' has-unseen-change' : '') +
        '" type="button" data-following-index="' + index + '"><span>' +
        (item.has_unseen_change ? '<em>New activity</em>' : '') + '<strong>' +
        escapeHtml(item.title) + '</strong><small>' + escapeHtml(
        (item.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + item.repository + ' #' + item.number +
        ' · ' + item.state + ' · ' + formatTime(item.updated_at)) +
        '</small></span><span aria-hidden="true">›</span></button>').join('');
      list.querySelectorAll('[data-following-index]').forEach(button => button.addEventListener('click', () => {
        query('#following-sheet').close();
        feature.open(Number(button.dataset.followingIndex)).catch(() => {});
      }));
    }
    feature = createFollowing({
      fetchJson, render,
      onCount:count => {
        const value = query('[data-mobile-queue-count="following"]');
        value.textContent = count;
        value.closest('button').setAttribute('aria-label', 'Following, ' + count +
          (count === 1 ? ' unseen change' : ' unseen changes'));
        hooks.onCount?.(count, feature.items());
      },
      onOpen:item => {
        activeReviewItem = item;
        return onOpen(item);
      },
      onReviewComplete:hooks.onReviewComplete,
      onAcknowledge:item => changeRevision(item, 'seen'),
      onKeep:item => changeRevision(item, 'keep'),
    });
    query('#close-following').addEventListener('click', () => query('#following-sheet').close());
    query('#review-following').addEventListener('click', () => {
      query('#following-sheet').close();
      feature.startReview().catch(() => {
        show();
      });
    });
    query('#retry-following').addEventListener('click', () => feature.load().catch(() => {}));
    const keepButton = query('#keep-following-for-later');
    keepButton.addEventListener('click', async () => {
      const status = query('#keep-following-status');
      keepButton.disabled = true;
      status.textContent = 'Keeping this change for later…';
      try {
        if (!await feature.keepForLater(activeReviewItem)) query('#close-search-preview').click();
        status.textContent = 'Kept for later.';
      } catch (_) {
        status.textContent = 'Could not keep this change for later. Retry when ready.';
        keepButton.disabled = false;
      }
    });
    return {
      load:feature.load,
      review:feature.startReview,
      async route() {
        await feature.load().then(feature.startReview).catch(() => false) || show();
      },
      open() {
        show();
        feature.load().catch(() => {});
        return 'opened-following';
      },
      session:feature.session,
      previewLoaded:feature.previewLoaded,
      keepForLater:feature.keepForLater,
      retire:feature.retire,
      preview(state) {
        keepButton.hidden = state?.item?.following !== true;
        keepButton.disabled = false;
        if (keepButton.hidden || state.status === 'loading') query('#keep-following-status').textContent = '';
      },
      returnToFollowing() {
        const completed = feature.finishReview();
        if (completed) return 'completed-following';
        show();
        globalThis.requestAnimationFrame?.(() => {
          const target = query('#review-following:not([hidden])') || query('.following-card') || query('#close-following');
          target?.focus();
        });
        return 'returned-following';
      },
    };
  }

  return {createFollowing, attachFollowing};
});
