function createTodayProgress({ storage, getLogin = () => '', admit, makeId = () =>
  globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
maxLength = 2000, maxItems = 20 }) {
  const prefix = 'stackchain.today-progress.v1.';
  const login = () => String(getLogin() || '').trim().toLowerCase();
  const storageKey = () => login() ? prefix + encodeURIComponent(login()) : '';
  const validIdentity = identity => typeof identity === 'string' && identity.length > 0 && identity.length <= 500;
  const attachmentFingerprint = attachments => JSON.stringify((Array.isArray(attachments) ? attachments : []).filter(Boolean).slice(0, 5).map(value => ({
    filename:String(value.filename || ''), contentType:String(value.contentType || ''),
    note:String(value.note || '').replace(/\s+/g, ' ').trim().slice(0, 240),
    operationId:String(value.operationId || '').slice(0, 128),
    markdown:String(value.confirmed?.markdown || ''),
  })));
  const payloadFingerprint = (body, attachments) => JSON.stringify({body, attachments:attachmentFingerprint(attachments)});
  const validRecord = record => record && typeof record.body === 'string' && (record.body.length > 0 || record.has_attachments === true) &&
    record.body.length <= maxLength && typeof record.operation_id === 'string' && record.operation_id.length > 0 &&
    record.operation_id.length <= 128;

  function read() {
    const key = storageKey();
    if (!storage || !key) return {};
    try {
      const saved = JSON.parse(storage.getItem(key) || 'null');
      if (!saved || saved.version !== 1 || !saved.drafts || typeof saved.drafts !== 'object' ||
          Array.isArray(saved.drafts)) return {};
      const entries = Object.entries(saved.drafts).filter(([identity, record]) =>
        validIdentity(identity) && validRecord(record)
      ).slice(0, maxItems);
      return Object.fromEntries(entries);
    } catch (_error) {
      return {};
    }
  }

  function write(drafts) {
    const key = storageKey();
    if (!storage || !key) return false;
    try {
      if (Object.keys(drafts).length) storage.setItem(key, JSON.stringify({ version:1, drafts }));
      else storage.removeItem(key);
      return true;
    } catch (_error) {
      return false;
    }
  }

  function load(identity) {
    if (!validIdentity(identity)) return '';
    return read()[identity]?.body || '';
  }

  function save(identity, value, attachments = []) {
    if (!validIdentity(identity) || !storageKey()) return false;
    const body = String(value || '').trim();
    const hasAttachments = attachments === true || (Array.isArray(attachments) && attachments.filter(Boolean).length > 0);
    if (body.length > maxLength) return false;
    const drafts = read();
    if (!body && !hasAttachments) {
      delete drafts[identity];
      return write(drafts);
    }
    if (!drafts[identity] && Object.keys(drafts).length >= maxItems) return false;
    const previous = drafts[identity];
    const fingerprint = payloadFingerprint(body, Array.isArray(attachments) ? attachments : []);
    const previousFingerprint = previous?.payload_fingerprint ||
      (previous && !previous.has_attachments ? payloadFingerprint(previous.body, []) : '');
    drafts[identity] = {
      body,
      operation_id: previousFingerprint === fingerprint ? previous.operation_id : String(makeId()).slice(0, 128),
      payload_fingerprint:fingerprint,
      ...(hasAttachments ? {has_attachments:true} : {}),
    };
    return write(drafts);
  }

  function validTarget(target) {
    return target && ['issue', 'pull'].includes(target.kind) && validIdentity(target.identity) &&
      typeof target.repository === 'string' && target.repository.length > 0 && target.repository.length <= 200 &&
      Number.isInteger(target.number) && target.number > 0;
  }

  async function post(target, value, attachments = [], completeEvidence) {
    if (!validTarget(target)) throw new Error('An active Today issue or pull request is required.');
    const pendingRecord = read()[target.identity];
    if (pendingRecord?.cleanup_pending === true) {
      try {
        if (typeof completeEvidence === 'function') await completeEvidence();
      } catch (_error) {
        const error = new Error('Progress update is already queued; photo cleanup is pending.');
        error.deliveryAdmitted = true;
        throw error;
      }
      const pendingDrafts = read();
      if (pendingDrafts[target.identity]?.operation_id === pendingRecord.operation_id) {
        delete pendingDrafts[target.identity];
        write(pendingDrafts);
      }
      return { background:true, alreadyAdmitted:true, cleanupRecovered:true };
    }
    const evidence = (Array.isArray(attachments) ? attachments : [attachments]).filter(Boolean).slice(0, 5);
    if ((value !== undefined || evidence.length) && !save(target.identity, value ?? load(target.identity), evidence)) {
      throw new Error('Progress update could not be saved on this device.');
    }
    const record = read()[target.identity];
    if (!record) throw new Error('Write a progress update before posting.');
    if (typeof admit !== 'function') throw new Error('Progress update delivery is unavailable.');
    const admission = await admit({
      kind:target.kind + '-comment', repository:target.repository, number:target.number,
      body:record.body, operationId:record.operation_id,
      ...(evidence.length ? {attachments:evidence} : {}),
    });
    const admittedDrafts = read();
    if (admittedDrafts[target.identity]?.operation_id === record.operation_id) {
      admittedDrafts[target.identity] = { ...admittedDrafts[target.identity], cleanup_pending:true };
      write(admittedDrafts);
    }
    try {
      if (typeof completeEvidence === 'function') await completeEvidence();
    } catch (_error) {
      const error = new Error('Progress update is already queued; photo cleanup is pending.');
      error.deliveryAdmitted = true;
      throw error;
    }
    const drafts = read();
    if (drafts[target.identity]?.operation_id === record.operation_id) {
      delete drafts[target.identity];
      write(drafts);
    }
    return admission;
  }

  async function postBlocker(target, value, attachments = [], completeEvidence, until, transition) {
    if (!validTarget(target)) throw new Error('An active Today issue or pull request is required.');
    if (typeof transition !== 'function') throw new Error('Today planning is unavailable.');
    let record = read()[target.identity];
    let admission = { background:true, alreadyAdmitted:true };
    if (record?.blocker_pending !== true) {
      const body = String(value ?? record?.body ?? '').trim();
      if (!body) throw new Error('Describe what is blocking this Today item.');
      const evidence = (Array.isArray(attachments) ? attachments : [attachments]).filter(Boolean).slice(0, 5);
      if (!save(target.identity, body, evidence)) {
        throw new Error('Blocker update could not be saved on this device.');
      }
      record = read()[target.identity];
      if (typeof admit !== 'function') throw new Error('Progress update delivery is unavailable.');
      admission = await admit({
        kind:target.kind + '-comment', repository:target.repository, number:target.number,
        body:record.body, operationId:record.operation_id,
        ...(evidence.length ? {attachments:evidence} : {}),
      });
      const drafts = read();
      if (drafts[target.identity]?.operation_id === record.operation_id) {
        drafts[target.identity] = { ...drafts[target.identity], blocker_pending:true, blocker_until:String(until || '') };
        write(drafts);
      }
    } else {
      const replacementUntil = String(until || '').trim();
      until = replacementUntil || record.blocker_until;
      if (replacementUntil && replacementUntil !== record.blocker_until) {
        const drafts = read();
        if (drafts[target.identity]?.operation_id === record.operation_id) {
          drafts[target.identity] = { ...drafts[target.identity], blocker_until:replacementUntil };
          write(drafts);
          record = drafts[target.identity];
        }
      }
    }
    try {
      if (typeof completeEvidence === 'function') await completeEvidence();
    } catch (_error) {
      const error = new Error('Blocker is already queued; photo cleanup is pending.');
      error.deliveryAdmitted = true;
      throw error;
    }
    if (await transition(target, until) !== true) {
      const error = new Error('Blocker was posted, but Today could not move on. Retry the planning step.');
      error.deliveryAdmitted = true;
      throw error;
    }
    const drafts = read();
    if (drafts[target.identity]?.operation_id === record.operation_id) {
      delete drafts[target.identity];
      write(drafts);
    }
    return admission;
  }

  function blockerRecovery(identity) {
    if (!validIdentity(identity)) return null;
    const record = read()[identity];
    return record?.blocker_pending === true ? { pending:true, until:String(record.blocker_until || '') } : null;
  }

  return { load, save, discard:identity => save(identity, ''), post, postBlocker, blockerRecovery };
}

function createTodayProgressActivity({ fetchJson, createPager, getActions, onActions, surfaceStatus, paint = () => {}, setStatus = () => {} }) {
  let requestToken = 0;
  let pager = null;
  let target = null;
  let actions = null;

  const pathFor = (value, page) => {
    const repository = String(value.repository || '').split('/').map(encodeURIComponent).join('/');
    const resource = value.kind === 'pull' ? 'pulls' : 'issues';
    return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(value.number) +
      '/comments?' + (Number.isInteger(page) ? 'page=' + encodeURIComponent(page) + '&' : '') + 'limit=20';
  };
  const loadPage = page => fetchJson(pathFor(target, page));

  async function open(value) {
    const token = ++requestToken;
    target = { ...value };
    setStatus('Loading recent activity…');
    try {
      const page = await loadPage();
      if (token !== requestToken || target.identity !== value.identity) return false;
      pager = createPager({ loadPage });
      actions = typeof getActions === 'function' ? await getActions() : null;
      if (token !== requestToken || target.identity !== value.identity) return false;
      onActions?.(actions);
      paint(pager.reset(page));
      setStatus('');
      return true;
    } catch (error) {
      if (token === requestToken) setStatus('Recent activity unavailable. Retry.');
      return false;
    }
  }

  async function loadOlder() {
    if (!pager || !target) return false;
    const token = requestToken;
    setStatus('Loading older activity…');
    try {
      const page = await pager.loadOlder();
      if (token !== requestToken) return false;
      paint(page);
      setStatus('');
      return true;
    } catch (_error) {
      if (token === requestToken) setStatus('Older activity unavailable. Retry.');
      return false;
    }
  }

  return {
    open,
    retry:() => target ? open(target) : Promise.resolve(false),
    loadOlder,
    actionHtml:comment => actions?.actionHtml?.(comment) || '',
    surface:() => ({
      context:{ kind:target?.kind, item:target }, pager,
      status:surfaceStatus, render:paint,
    }),
    close:() => { requestToken++; target = null; pager = null; actions = null; setStatus(''); },
  };
}

function mountTodayProgressActivity(qs, fetchJson, actionSource = {}, render) {
  const options = actionSource.get && !actionSource.getActions ?
    { getActions:actionSource.get, isOffline:() => !navigator.onLine } : actionSource;
  const escape = options.escape || (value => String(value).replace(/[&<>"']/g,
    character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[character]));
  render ||= options.render;
  const list = qs('#today-progress-activity-list');
  const optionsStatus = qs('#today-progress-activity-status');
  let actionsWired = false;
  const activity = createTodayProgressActivity({
    fetchJson,
    createPager:options.createPager || createConversationPager,
    getActions:options.getActions,
    surfaceStatus:optionsStatus,
    onActions:actions => {
      if (actionsWired || !actions?.wire) return;
      actions.wire({ root:list, getSurface:() => activity.surface(),
        isOffline:options.isOffline || (() => false), escapeHtml:escape });
      actionsWired = true;
    },
    paint:state => {
      list.innerHTML = (state.comments || []).map(comment => {
        const author = comment.author || comment.user?.login || 'Unknown author';
        const timing = comment.created_at ? ' · ' + new Date(comment.created_at).toLocaleString() : '';
        return '<article class="today-progress-activity-item issue-comment" data-comment-id="' +
          escape(String(comment.id)) + '"><div class="small muted">' +
          escape(author) + escape(timing) + '</div><div class="markdown-content">' +
          render(comment.body || 'No message body provided.') + '</div>' +
          activity.actionHtml(comment) + '</article>';
      }).join('');
      qs('#today-progress-activity-count').textContent = state.total ?
        String(state.comments.length) + ' of ' + String(state.total) + ' messages' : '';
      qs('#load-older-today-progress-activity').hidden = !Number.isInteger(state.older_page);
    },
    setStatus:message => {
      optionsStatus.textContent = message;
      qs('#retry-today-progress-activity').hidden = !message.includes('unavailable');
    },
  });
  qs('#retry-today-progress-activity').addEventListener('click', () => activity.retry());
  qs('#load-older-today-progress-activity').addEventListener('click', async () => {
    const list = qs('#today-progress-activity-list');
    const previousHeight = list.scrollHeight;
    const button = qs('#load-older-today-progress-activity');
    button.disabled = true;
    await activity.loadOlder();
    list.scrollTop += list.scrollHeight - previousHeight;
    button.disabled = false;
  });
  return activity;
}

function createTodayProgressView({ progress, currentTarget, qs, photos, voice, activity, mentions, announce = () => {}, onAdmitted = () => {}, moveOn = null, now = () => new Date() }) {
  const sheet = qs('#today-progress-sheet');
  const panel = qs('.today-progress-panel');
  const title = qs('#today-progress-title');
  const body = qs('#today-progress-body');
  const bodyLabel = qs('#today-progress-body-label');
  const status = qs('#today-progress-status');
  const launcher = qs('[data-mobile-today-update]');
  const blockerLauncher = qs('[data-mobile-today-blocked]');
  const blockerButton = qs('#post-today-blocker');
  const blockerReturn = qs('#today-blocker-return-at');
  const blockerRecovery = qs('#today-progress-blocker-recovery');
  const saveButton = qs('#save-today-progress');
  const postButton = qs('#post-today-progress');
  let openedTarget = null;

  const localDateTime = value => {
    const date = new Date(value || '');
    if (!Number.isFinite(date.getTime())) return '';
    return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
  };
  const showBlockerRecovery = recovery => {
    const pending = recovery?.pending === true;
    body.disabled = pending;
    saveButton.disabled = pending;
    postButton.disabled = pending;
    if (blockerButton) blockerButton.textContent = pending ? 'Finish moving on' : 'Post blocker & move on';
    if (blockerReturn) blockerReturn.value = pending ? localDateTime(recovery.until) : '';
    if (blockerRecovery) {
      blockerRecovery.hidden = !pending;
      blockerRecovery.textContent = pending ? 'Blocker queued—finish moving on. The posted update cannot be changed.' : '';
    }
  };

  const update = () => {
    const target = currentTarget();
    launcher.hidden = !target;
    if (blockerLauncher) blockerLauncher.hidden = !target;
    if (openedTarget && target?.identity !== openedTarget.identity) mentions?.dismiss?.();
  };
  const checkpoint = async () => {
    if (!openedTarget) return false;
    try {
      await photos?.checkpoint?.();
      const checkpointAttachments = await photos?.serialize?.() || [];
      if (progress.save(openedTarget.identity, body.value, checkpointAttachments)) return true;
      status.textContent = 'Update must be 2,000 characters or fewer and device storage must be available.';
      return false;
    } catch (error) {
      status.textContent = error.message + ' Your photos remain here; retry.';
      return false;
    }
  };
  const close = () => {
    voice?.cancel?.();
    activity?.close?.();
    mentions?.dismiss?.();
    if (sheet.open) sheet.close();
    panel?.classList?.remove('blocker-mode');
    openedTarget = null;
  };

  const open = async blockerMode => {
    const target = currentTarget();
    if (!target) return;
    mentions?.dismiss?.();
    openedTarget = target;
    panel?.classList?.[blockerMode ? 'add' : 'remove']('blocker-mode');
    if (title) title.textContent = blockerMode ? 'Report blocker' : 'Add progress update';
    if (bodyLabel) bodyLabel.textContent = blockerMode ? 'Blocker description' : 'Update';
    qs('#today-progress-target').textContent = target.label + (target.title ? ' · ' + target.title : '');
    body.value = progress.load(target.identity);
    showBlockerRecovery(progress.blockerRecovery?.(target.identity));
    sheet.showModal();
    if (!blockerMode) {
      status.textContent = 'Restoring saved photo evidence…';
      try {
        await voice?.open?.(target.identity);
        await photos?.open?.(target);
        await activity?.open?.(target);
        status.textContent = '';
      }
      catch (error) { status.textContent = error.message + ' You can retry by reopening this update.'; }
    }
    else status.textContent = '';
    body.focus();
  };
  launcher.addEventListener('click', () => open(false));
  blockerLauncher?.addEventListener('click', () => open(true));

  const setReturn = preset => {
    const date = new Date(now());
    if (preset === 'later-today') date.setHours(date.getHours() + 4);
    else {
      date.setDate(date.getDate() + 1);
      date.setHours(9, 0, 0, 0);
    }
    blockerReturn.value = localDateTime(date);
  };
  qs('[data-today-blocker-preset="later-today"]')?.addEventListener('click', () => setReturn('later-today'));
  qs('[data-today-blocker-preset="tomorrow"]')?.addEventListener('click', () => setReturn('tomorrow'));
  qs('[data-today-blocker-custom]')?.addEventListener('click', () => blockerReturn?.focus?.());
  qs('#cancel-today-progress').addEventListener('click', async () => {
    if (await checkpoint()) close();
  });
  sheet.addEventListener('cancel', async event => {
    event.preventDefault();
    if (await checkpoint()) close();
  });
  qs('#save-today-progress').addEventListener('click', async () => {
    if (!await checkpoint()) return;
    announce('Progress update saved privately to this Today item.');
    close();
  });
  blockerButton?.addEventListener('click', async () => {
    const target = currentTarget();
    if (!target || target.identity !== openedTarget?.identity) {
      status.textContent = 'The active Today item changed. Close and open its update again.';
      return;
    }
    const returnInput = blockerReturn;
    const wakeAt = new Date(returnInput?.value || '');
    if (!body.value.trim()) {
      status.textContent = 'Describe what is blocking this Today item.';
      body.focus();
      return;
    }
    if (!Number.isFinite(wakeAt.getTime()) || wakeAt.getTime() <= Date.now()) {
      status.textContent = 'Choose a valid future return time.';
      returnInput?.focus?.();
      return;
    }
    blockerButton.disabled = true;
    status.textContent = 'Posting blocker before moving Today…';
    try {
      await photos?.checkpoint?.();
      const attachments = await photos?.serialize?.() || [];
      const admission = await progress.postBlocker(
        target, body.value, attachments, () => photos?.complete?.(), wakeAt.toISOString(), moveOn
      );
      onAdmitted(admission);
      announce('Blocker queued, deferred to Later, and Today moved on.');
      close();
    } catch (error) {
      status.textContent = error.deliveryAdmitted ? error.message : error.message + ' Your blocker remains here; retry.';
      body.focus();
    } finally {
      blockerButton.disabled = false;
    }
  });
  qs('#post-today-progress').addEventListener('click', async () => {
    const target = currentTarget();
    if (!target || target.identity !== openedTarget?.identity) {
      status.textContent = 'The active Today item changed. Close and open its update again.';
      return;
    }
    const button = qs('#post-today-progress');
    button.disabled = true;
    status.textContent = 'Saving for delivery…';
    try {
      await photos?.checkpoint?.();
      const attachments = await photos?.serialize?.() || [];
      const admission = await progress.post(target, body.value, attachments, () => photos?.complete?.());
      onAdmitted(admission);
      announce(admission.background ?
        'Progress update queued for delivery. Today is still on the same item.' :
        'Progress update saved for next launch. Today is still on the same item.');
      close();
    } catch (error) {
      status.textContent = error.deliveryAdmitted ?
        error.message + ' Retry to finish local cleanup; delivery will not be queued again.' :
        error.message + ' Your update remains on this item; retry.';
      body.focus();
    } finally {
      button.disabled = false;
    }
  });
  update();
  return { update };
}

function createTodayProgressPhotos({ qs, document, issueAttachment, fetchJson, createStore, createDrafts, getLogin }) {
  let target = null;
  let drafts = null;
  const controller = issueAttachment.mount({
    maxFiles:5, input:qs('#today-progress-attachment'),
    inputs:[qs('#take-today-progress-photo'), qs('#today-progress-attachment')],
    preview:qs('#today-progress-attachment-preview'), image:qs('#today-progress-attachment-image'),
    meta:qs('#today-progress-attachment-meta'), remove:qs('#remove-today-progress-attachment'),
    tray:qs('#today-progress-attachment-tray'), earlier:qs('#move-today-progress-attachment-earlier'),
    later:qs('#move-today-progress-attachment-later'), note:qs('#today-progress-attachment-note'),
    noteLabel:qs('#today-progress-attachment-note-label'), status:qs('#today-progress-status'),
    onChange:() => drafts?.checkpoint('today').catch(() => {}),
    onCheckpoint:() => drafts.checkpoint('today'),
    readyMessage:'Photo ready to post with this Today update.',
    removedMessage:'Photo removed. Your progress text is unchanged.',
    editor:{
      document, edit:qs('#edit-today-progress-attachment'), dialog:qs('#issue-evidence-editor'),
      canvas:qs('#issue-evidence-editor-canvas'), exportCanvas:qs('#issue-evidence-editor-export'),
      crop:qs('#crop-issue-evidence'), redact:qs('#redact-issue-evidence'),
      highlight:qs('#highlight-issue-evidence'), arrow:qs('#arrow-issue-evidence'),
      undo:qs('#undo-issue-evidence-edit'), reset:qs('#reset-issue-evidence-edit'),
      cancel:qs('#cancel-issue-evidence-edit'), apply:qs('#apply-issue-evidence-edit'),
      status:qs('#issue-evidence-editor-status'), appliedMessage:'Edited photo ready for this Today update.',
    },
    createObjectURL:file => URL.createObjectURL(file), revokeObjectURL:url => URL.revokeObjectURL(url),
    upload:payload => {
      if (!target || target.repository !== payload.repository || Number(target.number) !== Number(payload.number)) {
        return Promise.reject(new Error('The active Today item changed. Reopen its update before posting.'));
      }
      const repository = payload.repository.split('/').map(encodeURIComponent).join('/');
      const resource = target.kind === 'pull' ? 'pulls' : 'issues';
      return fetchJson('api/v1/repos/' + repository + '/' + resource + '/' +
        encodeURIComponent(payload.number) + '/attachments', {
        method:'POST', headers:{Accept:'application/json','Idempotency-Key':payload.operation_id},
        body:issueAttachment.multipart(payload),
      });
    },
  });
  const store = createStore({ indexedDB:globalThis.indexedDB, getOwnerLogin:getLogin, scope:'today-progress' });
  drafts = createDrafts({ store, lanes:{ today:{ controller, onError:error => {
    qs('#today-progress-status').textContent = error.message + ' Your photos remain here; retry.';
  } } } });
  return {
    has:() => Boolean(controller.state()), serialize:() => controller.serialize(),
    checkpoint:() => drafts.checkpoint('today'),
    open:async value => {
      if (drafts.hasTarget('today')) await drafts.switchTo('today', value);
      else await drafts.open('today', value);
      target = { ...value };
    },
    complete:async () => { await drafts.complete('today'); target = null; },
  };
}

if (typeof module !== 'undefined' && module.exports) {
  module.exports = createTodayProgress;
  module.exports.createView = createTodayProgressView;
  module.exports.createPhotos = createTodayProgressPhotos;
  module.exports.createActivity = createTodayProgressActivity;
  module.exports.mountActivity = mountTodayProgressActivity;
}
