function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelection = () => {}, onFacets = () => {} }) {
  let available = [];
  let pagination = { page: 1, total: 0, has_more: false };
  let loadRequest = null;
  let loadGeneration = 0;
  let query = '';
  let filters = { repositories: [], labels: [] };
  let facetView;
  let claimRequest = null;
  const previewed = new Set();
  let selecting = false;
  const selected = new Map();

  function itemKey(item) {
    return String(item?.repository || '') + '#' + String(item?.number || '');
  }

  function emitSelection() {
    const state = { active: selecting, count: selected.size, ids: Array.from(selected.keys()) };
    onSelection(state);
    return state;
  }

  function apply(result, append) {
    const incoming = Array.isArray(result?.items) ? result.items : [];
    if (append) {
      const byId = new Map(available.map(item => [item.id, item]));
      incoming.forEach(item => {
        if (item && !byId.has(item.id)) byId.set(item.id, { ...item });
      });
      available = Array.from(byId.values());
    } else {
      available = incoming.map(item => ({ ...item }));
    }
    const availableKeys = new Set(available.map(itemKey));
    previewed.forEach(key => {
      if (!availableKeys.has(key)) previewed.delete(key);
    });
    pagination = {
      page: Number(result?.page) || 1,
      total: Number(result?.total) || available.length,
      has_more: result?.has_more === true,
    };
    onItems(available.slice());
    onPagination({ ...pagination });
    if (result?.facets) onFacets({
      repositories: Array.isArray(result.facets.repositories) ? result.facets.repositories.slice() : [],
      labels: Array.isArray(result.facets.labels) ? result.facets.labels.slice() : [],
    });
    if (result?.facets && typeof document !== 'undefined') {
      facetView ||= attachFindWorkFacets(document.querySelector('#find-work-filters'), value =>
        String(value).replace(/[&<>"']/g, character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character])),
      next => { filters = next; loadPage(1, false); });
      facetView(result.facets);
    }
  }

  function loadPage(page, append, requestedQuery = query) {
    if (loadRequest && requestedQuery === query && append) return loadRequest;
    const generation = ++loadGeneration;
    const params = new URLSearchParams();
    params.set('facets', 'true');
    if (requestedQuery) params.set('q', requestedQuery);
    filters.repositories.forEach(value => params.append('repository', value));
    filters.labels.forEach(value => params.append('label', value));
    const queryPart = params.toString() ? '&' + params.toString() : '';
    const request = fetchJson('api/v1/available-issues?page=' + page + queryPart, {
      headers: { Accept: 'application/json' },
    }).then(result => {
      if (generation !== loadGeneration) return result;
      apply(result, append);
      if (result?.refresh_failed === true) {
        onStatus('Showing saved available work. Catalog refresh failed; retrying shortly.');
      } else if (result?.revalidating === true) {
        onStatus('Showing saved available work while the catalog refreshes…');
      }
      return result;
    }).finally(() => { if (loadRequest === request) loadRequest = null; });
    loadRequest = request;
    return request;
  }

  return {
    reset(result) {
      apply(result, false);
    },
    load() {
      return loadPage(1, false);
    },
    loadMore() {
      if (!pagination.has_more) return Promise.resolve(false);
      return loadPage(pagination.page + 1, true);
    },
    search(value) {
      query = String(value || '').trim();
      return loadPage(1, false, query);
    },
    query() {
      return query;
    },
    setFilters(next) {
      filters = {
        repositories: Array.from(new Set((next?.repositories || []).map(String))).slice(0, 10),
        labels: Array.from(new Set((next?.labels || []).map(String))).slice(0, 10),
      };
      return loadPage(1, false);
    },
    filters() {
      return { repositories: filters.repositories.slice(), labels: filters.labels.slice() };
    },

    items() {
      return available.slice();
    },
    startSelection() {
      selecting = true;
      selected.clear();
      return emitSelection();
    },
    cancelSelection() {
      selecting = false;
      selected.clear();
      return emitSelection();
    },
    toggleSelection(item) {
      if (!selecting || !item) return emitSelection();
      const key = itemKey(item);
      if (selected.has(key)) selected.delete(key);
      else selected.set(key, item);
      return emitSelection();
    },
    fillSelection(limit) {
      limit = Math.max(0, Math.floor(Number(limit) || 0));
      if (!limit) return { selected: selected.size, added: 0, limit };
      selecting = true;
      const before = selected.size;
      available.some(item => {
        if (selected.size >= limit) return true;
        selected.set(itemKey(item), item);
        return false;
      });
      emitSelection();
      return { selected: selected.size, added: selected.size - before, limit };
    },
    isSelected(item) {
      return selected.has(itemKey(item));
    },
    selectedItems() {
      return Array.from(selected.values());
    },
    selection() {
      return { active: selecting, count: selected.size, ids: Array.from(selected.keys()) };
    },
    togglePreview(item) {
      const key = itemKey(item);
      if (previewed.has(key)) previewed.delete(key);
      else previewed.add(key);
      return previewed.has(key);
    },
    isPreviewed(item) {
      return previewed.has(itemKey(item));
    },
    claim(item) {
      if (claimRequest) return claimRequest;
      const key = item.repository + '#' + item.number;
      onStatus('Assigning ' + key + '…');
      const path = 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
        '/issues/' + encodeURIComponent(item.number) + '/claim';
      claimRequest = fetchJson(path, {
        method: 'PATCH',
        headers: { Accept: 'application/json' },
      }).then(confirmed => {
        if (!Array.isArray(confirmed?.assignees) || !confirmed.assignees.length) {
          throw new Error('Issue assignment was not confirmed.');
        }
        available = available.filter(candidate =>
          candidate.repository !== item.repository || candidate.number !== item.number
        );
        pagination.total = Math.max(available.length, pagination.total - 1);
        pagination.has_more = available.length < pagination.total;
        previewed.delete(itemKey(item));
        selected.delete(itemKey(item));
        emitSelection();
        onItems(available.slice());
        onPagination({ ...pagination });
        onStatus('Assigned ' + key + ' to you.');
        return confirmed;
      }).finally(() => { claimRequest = null; });
      return claimRequest;
    },
  };
}

function attachFindWorkFacets(root, escapeHtml, onChange) {
  const values = name => Array.from(root.querySelectorAll('input[name="' + name + '"]:checked'), input => input.value);
  root.onchange = event => event.target.type === 'checkbox' && onChange({ repositories:values('find-work-repository'), labels:values('find-work-label') });
  root.querySelector('#clear-find-work-filters').onclick = () => {
    root.querySelectorAll('input').forEach(input => { input.checked = false; });
    onChange({ repositories:[], labels:[] });
  };
  return facets => {
    const active = { repositories:values('find-work-repository'), labels:values('find-work-label') };
    const render = (id, choices, selected, name) => {
      root.querySelector(id).innerHTML = choices.map(value => '<label class="find-work-filter-option"><input type="checkbox" name="' + name + '" value="' + escapeHtml(value) + '"' + (selected.includes(value) ? ' checked' : '') + ' />' + escapeHtml(value) + '</label>').join('');
    };
    render('#find-work-repository-filters', facets.repositories || [], active.repositories, 'find-work-repository');
    render('#find-work-label-filters', facets.labels || [], active.labels, 'find-work-label');
    const count = active.repositories.length + active.labels.length;
    root.querySelector('#find-work-filter-count').textContent = count ? '(' + count + ' active)' : '';
  };
}

if (typeof module !== 'undefined' && module.exports) module.exports = createFindWork;
