function todayRecapFeedbackRows(draft, describe = () => null) {
  return (draft?.items || []).map(item => {
    const work = describe(item.identity) || {};
    const estimate = Number.isInteger(item.estimate_minutes) ? item.estimate_minutes : null;
    return {
      identity:item.identity,
      label:String(work.title || item.identity).slice(0, 180),
      context:String(work.key || work.repository || 'Work details unavailable').slice(0, 180),
      estimate_minutes:estimate,
      actual_minutes:item.actual_minutes,
      variance_minutes:estimate === null ? null : item.actual_minutes - estimate,
    };
  });
}

function createTodayRecap({ save, saveAndLog = null, clear, makeId = () => crypto.randomUUID(), storage = null, getLogin = () => '' }) {
  const totals = items => {
    const estimated = items.reduce((sum, item) => sum + (item.estimate_minutes ?? 0), 0);
    const actual = items.reduce((sum, item) => sum + item.actual_minutes, 0);
    return { estimated_minutes:estimated, actual_minutes:actual, variance_minutes:actual - estimated };
  };
  const storageKey = () => {
    const login = String(getLogin() || '').trim().toLowerCase();
    return login ? 'stackchain.today-recap-draft.v1.' + login : '';
  };
  const handoffKey = () => {
    const login = String(getLogin() || '').trim().toLowerCase();
    return login ? 'stackchain.today-recap-handoff.v1.' + login : '';
  };
  const validStoredDraft = saved => {
    if (!saved || typeof saved.session_id !== 'string' || !saved.session_id.length || saved.session_id.length > 100 ||
        !Array.isArray(saved.items) || !saved.items.length || saved.items.length > 20) return false;
    const itemsValid = saved.items.every(item => item && typeof item.identity === 'string' && item.identity.length > 0 &&
      item.identity.length <= 500 && (item.estimate_minutes === null ||
        (Number.isInteger(item.estimate_minutes) && item.estimate_minutes >= 5 && item.estimate_minutes <= 1440)) &&
      Number.isInteger(item.actual_minutes) && item.actual_minutes >= 0 && item.actual_minutes <= 1440);
    if (!itemsValid) return false;
    const identities = new Set(saved.items.map(item => item.identity));
    if (saved.log_identities !== undefined && (!Array.isArray(saved.log_identities) ||
        saved.log_identities.length > 20 || saved.log_identities.some(identity => !identities.has(identity)))) return false;
    if (saved.time_logs !== undefined && (!saved.time_logs || typeof saved.time_logs !== 'object' ||
        Array.isArray(saved.time_logs) || Object.entries(saved.time_logs).some(([identity, status]) =>
          !identities.has(identity) || !['logged', 'retry', 'verify'].includes(status)))) return false;
    return true;
  };
  const load = () => {
    const key = storageKey();
    if (!storage || !key) return null;
    try {
      const saved = JSON.parse(storage.getItem(key) || 'null');
      if (!validStoredDraft(saved)) {
        if (saved !== null) storage.removeItem(key);
        return null;
      }
      return { ...saved, ...totals(saved.items) };
    }
    catch (_error) {
      try { storage.removeItem(key); } catch (_ignored) {}
      return null;
    }
  };
  const validHandoff = saved => {
    if (!saved || typeof saved.session_id !== 'string' || !saved.session_id.length || saved.session_id.length > 100 ||
        !saved.actual_minutes || typeof saved.actual_minutes !== 'object' || Array.isArray(saved.actual_minutes)) return false;
    const entries = Object.entries(saved.actual_minutes);
    return entries.length > 0 && entries.length <= 20 && entries.every(([identity, minutes]) =>
      identity.length > 0 && identity.length <= 500 && Number.isInteger(minutes) && minutes >= 0 && minutes <= 1440
    );
  };
  const pendingReplan = () => {
    const key = handoffKey();
    if (!storage || !key) return null;
    try {
      const saved = JSON.parse(storage.getItem(key) || 'null');
      if (!validHandoff(saved)) {
        if (saved !== null) storage.removeItem(key);
        return null;
      }
      return { session_id:saved.session_id, actual_minutes:{...saved.actual_minutes} };
    } catch (_error) {
      try { storage.removeItem(key); } catch (_ignored) {}
      return null;
    }
  };
  const completeReplan = () => {
    const key = handoffKey();
    if (storage && key) storage.removeItem(key);
  };
  let draftKey = storageKey();
  let draft = load();
  const persist = () => {
    const key = storageKey();
    if (!storage || !key || !draft) return;
    const saved = { session_id:draft.session_id, items:draft.items };
    if (draft.log_identities) saved.log_identities = draft.log_identities;
    if (draft.time_logs) saved.time_logs = draft.time_logs;
    storage.setItem(key, JSON.stringify(saved));
  };
  const restore = () => {
    const key = storageKey();
    if (key !== draftKey) {
      draft = null;
      draftKey = key;
    }
    if (!draft) draft = load();
    return Boolean(draft);
  };
  const snapshot = () => {
    restore();
    return draft ? {
      ...draft, items:draft.items.map(item => ({...item})),
      ...(draft.log_identities ? {log_identities:[...draft.log_identities]} : {}),
      ...(draft.time_logs ? {time_logs:{...draft.time_logs}} : {}),
    } : null;
  };
  const saveConfirmed = async includeActuals => {
    restore();
    if (!draft?.items.length) throw new Error('No timed work to save.');
    const payload = { session_id:draft.session_id, items:draft.items.map(item => ({...item})) };
    const actualMinutes = includeActuals ? Object.fromEntries(
      payload.items.map(item => [item.identity, item.actual_minutes])
    ) : null;
    const result = await save(payload);
    if (includeActuals && storage && handoffKey()) storage.setItem(handoffKey(), JSON.stringify({
      session_id:payload.session_id, actual_minutes:actualMinutes,
    }));
    clear();
    if (storage && draftKey) storage.removeItem(draftKey);
    draft = null;
    return includeActuals ? { result, actual_minutes:actualMinutes } : result;
  };
  const saveWithTime = async identities => {
    restore();
    if (!draft?.items.length) throw new Error('No timed work to save.');
    if (typeof saveAndLog !== 'function') throw new Error('Gitea time logging is unavailable.');
    const requested = new Set(Array.isArray(identities) ? identities : []);
    const eligible = new Set(draft.items.filter(item => item.actual_minutes > 0).map(item => item.identity));
    const intended = [...new Set([...(draft.log_identities || []), ...requested])]
      .filter(identity => eligible.has(identity));
    if (!intended.length) throw new Error('Select at least one non-zero time entry.');
    draft.log_identities = intended;
    draft.time_logs = draft.time_logs || {};
    persist();
    const logIdentities = intended.filter(identity => !['logged', 'verify'].includes(draft.time_logs[identity]));
    const payload = {
      session_id:draft.session_id,
      items:draft.items.map(item => ({...item})),
      log_identities:logIdentities,
    };
    const result = await saveAndLog(payload);
    Object.assign(draft.time_logs, Object.fromEntries((result.time_logs || []).map(item => [item.identity, item.status])));
    persist();
    const logged = intended.filter(identity => draft.time_logs[identity] === 'logged').length;
    const verify = intended.filter(identity => draft.time_logs[identity] === 'verify').length;
    const retry = intended.length - logged - verify;
    if (retry || verify) {
      if (verify) throw new Error(verify + ' time ' + (verify === 1 ? 'entry needs' : 'entries need') +
        ' verification in Gitea; it will not be posted again automatically.');
      throw new Error(logged + ' time ' + (logged === 1 ? 'entry' : 'entries') + ' logged; ' + retry + ' needs retry.');
    }
    clear();
    if (storage && draftKey) storage.removeItem(draftKey);
    draft = null;
    return result;
  };
  return {
    restore,
    begin(entries, estimates = {}) {
      const items = (entries || []).filter(entry => entry?.identity).map(entry => ({
        identity:String(entry.identity),
        estimate_minutes:Number.isInteger(estimates[entry.identity]) ? estimates[entry.identity] : null,
        actual_minutes:Math.min(1440, Math.max(0, Math.round(Number(entry.elapsed_ms || 0) / 60000))),
      }));
      draftKey = storageKey();
      draft = { session_id:makeId(), items, ...totals(items) };
      persist();
      return snapshot();
    },
    correct(identity, minutes) {
      restore();
      if (!draft || !Number.isInteger(minutes) || minutes < 0 || minutes > 1440) return false;
      const item = draft.items.find(candidate => candidate.identity === identity);
      if (!item) return false;
      item.actual_minutes = minutes;
      Object.assign(draft, totals(draft.items));
      persist();
      return true;
    },
    snapshot,
    save:() => saveConfirmed(false),
    saveForReplan:() => saveConfirmed(true),
    saveAndLog:saveWithTime,
    pendingReplan,
    completeReplan,
    discardReplan:completeReplan,
  };
}

async function saveTodayRecap(payload) {
  const response = await fetch('api/v1/today/recaps', {
    method:'POST', headers:{ Accept:'application/json', 'Content-Type':'application/json' },
    body:JSON.stringify(payload),
  });
  const result = await response.json().catch(() => ({}));
  if (!response.ok) throw new Error(result.detail || 'Recap could not be saved.');
  return result;
}

async function saveTodayRecapTime(payload) {
  const response = await fetch('api/v1/today/recaps/log-time', {
    method:'POST', headers:{ Accept:'application/json', 'Content-Type':'application/json' },
    body:JSON.stringify(payload),
  });
  const result = await response.json().catch(() => ({}));
  if (!response.ok) throw new Error(result.detail || 'Recap time could not be logged.');
  return result;
}

function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, escapeHtml,
  describeWork = () => null, openWrapUp = () => {} }) {
  const minutes = value => String(Math.max(0, Number(value) || 0)) + 'm';
  const render = () => {
    const draft = recap.snapshot();
    const container = qs('#today-recap-items');
    const rows = todayRecapFeedbackRows(draft, describeWork);
    container.innerHTML = rows.map(item => {
      const variance = item.variance_minutes === null ? 'No estimate comparison' :
        (item.variance_minutes === 0 ? 'On estimate' : minutes(Math.abs(item.variance_minutes)) +
          (item.variance_minutes > 0 ? ' over estimate' : ' under estimate'));
      const canonical = /^(issue|pull):[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:[1-9][0-9]*:$/.test(item.identity);
      const logStatus = draft?.time_logs?.[item.identity];
      const logControl = logStatus === 'logged' ?
        '<span class="today-recap-log today-recap-log-success">Logged to Gitea</span>' :
        logStatus === 'verify' ? '<span class="today-recap-log today-recap-log-verify">Verify this entry in Gitea</span>' :
        item.actual_minutes > 0 && canonical ?
        '<label class="today-recap-log"><input type="checkbox" data-recap-log-identity="' +
        escapeHtml(item.identity) + '"> ' + (logStatus === 'retry' ? 'Retry ' : 'Log ') +
        minutes(item.actual_minutes) + ' to Gitea</label>' :
        '<span class="small muted today-recap-log">No non-zero Gitea time to log</span>';
      return '<div class="today-recap-row"><span><strong>' + escapeHtml(item.label) + '</strong><span class="small today-recap-context">' +
      escapeHtml(item.context) + ' · ' + (item.estimate_minutes === null ? 'Not estimated' : minutes(item.estimate_minutes) + ' estimated') +
      '</span></span><span><input type="number" inputmode="numeric" min="0" max="1440" step="1" value="' +
      item.actual_minutes + '" data-recap-identity="' + escapeHtml(item.identity) + '" aria-label="Actual minutes for ' +
      escapeHtml(item.label) + '"> min</span><span class="today-recap-variance">' + variance + '</span>' + logControl + '</div>';
    }).join('') || '';
    qs('#today-recap-totals').textContent = draft ? minutes(draft.estimated_minutes) + ' estimated · ' +
      minutes(draft.actual_minutes) + ' actual · ' + minutes(Math.abs(draft.variance_minutes)) +
      (draft.variance_minutes >= 0 ? ' over' : ' under') : '';
    container.querySelectorAll('[data-recap-identity]').forEach(input => input.addEventListener('change', () => {
      if (!recap.correct(input.dataset.recapIdentity, Number(input.value))) {
        qs('#today-recap-status').textContent = 'Actual time must be a whole number from 0 to 1,440 minutes.';
      }
      render();
    }));
  };
  const loadHistory = async () => {
    qs('#today-recap-history').innerHTML = '<div class="small">Loading recent recaps…</div>';
    try {
      const result = await api('api/v1/today/recaps');
      qs('#today-recap-history').innerHTML = (result.recaps || []).map(saved =>
        '<div class="today-recap-history-row"><span>' + new Date(saved.created_at * 1000).toLocaleString() +
        '</span><strong>' + minutes(saved.actual_minutes) + ' actual · ' + minutes(Math.abs(saved.variance_minutes)) +
        (saved.variance_minutes >= 0 ? ' over' : ' under') + '</strong></div>'
      ).join('') || '<div class="small">No saved recaps yet.</div>';
    } catch (_error) {
      qs('#today-recap-history').innerHTML = '<div class="small">Recent recaps are unavailable. Try again.</div>';
    }
  };
  const open = ({ begin = false } = {}) => {
    if (begin) recap.begin(timer.recapEntries(), todayWork.planning().estimates);
    else recap.restore();
    render();
    qs('#save-today-recap').hidden = !recap.snapshot()?.items.length;
    qs('#save-log-today-recap').hidden = !recap.snapshot()?.items.length;
    qs('#today-recap-sheet').hidden = false;
    document.body.classList.add('task-overlay-open');
    loadHistory();
    requestAnimationFrame(() => (qs('#today-recap-items input') || qs('#close-today-recap')).focus());
  };
  const close = () => {
    qs('#today-recap-sheet').hidden = true;
    document.body.classList.remove('task-overlay-open');
    qs('#open-today-recaps').focus();
  };
  const saveDraft = async button => {
    const workedItems = todayRecapFeedbackRows(recap.snapshot(), describeWork);
    button.disabled = true;
    qs('#today-recap-status').textContent = 'Saving recap…';
    try {
      const handoff = await recap.saveForReplan();
      qs('#today-recap-status').textContent = 'Recap saved to your account.';
      await loadHistory(); render(); button.hidden = true;
      close();
      openWrapUp(handoff.actual_minutes, workedItems);
    } catch (error) {
      qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.';
    } finally { button.disabled = false; }
  };
  const saveTimeDraft = async button => {
    const selected = Array.from(qs('#today-recap-items').querySelectorAll('[data-recap-log-identity]:checked'))
      .map(input => input.dataset.recapLogIdentity);
    if (!selected.length) {
      qs('#today-recap-status').textContent = 'Select at least one non-zero time entry.';
      return;
    }
    const actualMinutes = Object.fromEntries(recap.snapshot().items.map(item => [item.identity, item.actual_minutes]));
    const workedItems = todayRecapFeedbackRows(recap.snapshot(), describeWork);
    button.disabled = true;
    qs('#today-recap-status').textContent = 'Logging selected time…';
    try {
      await recap.saveAndLog(selected);
      qs('#today-recap-status').textContent = 'Selected time logged to Gitea.';
      await loadHistory(); render(); button.hidden = true;
      close();
      openWrapUp(actualMinutes, workedItems);
    } catch (error) {
      qs('#today-recap-status').textContent = error.message || 'Time could not be logged. Your recap is ready to retry.';
      render();
    } finally { button.disabled = false; }
  };
  const bind = () => {
    qs('#open-today-recaps').addEventListener('click', () => open());
    qs('#close-today-recap').addEventListener('click', close);
    qs('#discard-today-recap').addEventListener('click', close);
    qs('#save-today-recap').addEventListener('click', event => saveDraft(event.currentTarget));
    qs('#save-log-today-recap').addEventListener('click', event => saveTimeDraft(event.currentTarget));
  };
  return {
    open, close, saveDraft, saveTimeDraft, loadHistory, render, bind,
    pendingReplan:recap.pendingReplan,
    completeReplan:recap.completeReplan,
    discardReplan:recap.discardReplan,
  };
}

function endTodaySession(workSession, qs) {
  workSession.end();
  qs('#my-work-action-status').textContent = 'Today session ended. Your plan is unchanged.';
}

function openTodayRecapAfterSession(view, timer, timerView, workFilter, qs) {
  timerView.finish();
  document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; });
  qs('#my-work-action-status').textContent = 'Work session complete.';
  if (workFilter === 'today' && timer.recapEntries().length) view.open({ begin:true });
  else qs('#start-work-session').focus();
}

function setupTodayRecap(timer, timerView, todayWork, api, qs, escapeHtml, closeSheets, updateActions, getLogin,
  describeWork, openWrapUp) {
  const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, openWrapUp };
  const recap = createTodayRecap({
    save:saveTodayRecap, saveAndLog:saveTodayRecapTime,
    clear:() => timer.clearRecap(), storage:localStorage, getLogin,
  });
  const view = createTodayRecapView({ ...options, recap });
  view.bind();
  setInterval(timerView.render, 1000);
  view.finish = workFilter => {
    closeSheets();
    updateActions();
    openTodayRecapAfterSession(view, timer, timerView, workFilter, qs);
  };
  return view;
}

if (typeof module !== 'undefined' && module.exports) {
  createTodayRecap.createView = createTodayRecapView;
  createTodayRecap.feedbackRows = todayRecapFeedbackRows;
  module.exports = createTodayRecap;
}
