function createReviewController({
  fetchJson,
  storage,
  createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
}) {
  let pendingSubmission = null;
  let pendingChecks = null;

  function endpoint(item) {
    const [owner, repo] = String(item.repository || '').split('/');
    if (!owner || !repo || !Number.isInteger(Number(item.number))) {
      throw new Error('This review link is invalid.');
    }
    return 'api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo) +
      '/pulls/' + Number(item.number) + '/review';
  }

  async function load(item) {
    return fetchJson(endpoint(item), { headers: { Accept: 'application/json' } });
  }

  function loadChecks(item) {
    if (pendingChecks) return pendingChecks;
    pendingChecks = fetchJson(endpoint(item) + '/checks', {
      headers: { Accept: 'application/json' },
    }).finally(() => { pendingChecks = null; });
    return pendingChecks;
  }

  function submit(item, payload) {
    if (pendingSubmission) return pendingSubmission;
    const operationKey = 'stackchain.review-submit.v1:' + item.repository + '#' + item.number;
    const fingerprint = JSON.stringify(payload);
    let operationId;
    try {
      const saved = JSON.parse(storage?.getItem(operationKey) || 'null');
      operationId = saved?.fingerprint === fingerprint && saved?.operationId
        ? saved.operationId : String(createOperationId()).slice(0, 128);
      storage?.setItem(operationKey, JSON.stringify({ fingerprint, operationId }));
    } catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
    pendingSubmission = fetchJson(endpoint(item), {
      method: 'POST',
      headers: {
        Accept: 'application/json', 'Content-Type': 'application/json',
        'Idempotency-Key': operationId,
      },
      body: JSON.stringify(payload),
    }).then(result => {
      try { storage?.removeItem(operationKey); }
      catch (_error) { /* A confirmed result no longer needs replay identity. */ }
      return result;
    }).finally(() => { pendingSubmission = null; });
    return pendingSubmission;
  }

  return { load, loadChecks, submit };
}

function createWrapPreference({ storage, mobile }) {
  const key = 'stackchain.review-wrap.v1';
  let explicit = false;
  let wrapped = Boolean(mobile);
  try {
    const saved = storage?.getItem(key);
    if (saved === 'true' || saved === 'false') {
      explicit = true;
      wrapped = saved === 'true';
    }
  } catch (_error) { /* keep the viewport default */ }

  function snapshot() { return { wrapped, explicit }; }
  function setWrapped(value) {
    wrapped = Boolean(value);
    explicit = true;
    try { storage?.setItem(key, String(wrapped)); } catch (_error) { /* keep in memory */ }
    return snapshot();
  }
  return { snapshot, setWrapped, storageKey: key };
}

function diffLineClass(line) {
  if (line.startsWith('@@')) return 'hunk';
  if (line.startsWith('+')) return 'added';
  if (line.startsWith('-')) return 'removed';
  return 'context';
}

function parseDiffLines(lines) {
  let oldLine = null;
  let newLine = null;
  return (lines || []).map(value => {
    const text = String(value);
    const hunk = text.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
    if (hunk) {
      oldLine = Number(hunk[1]);
      newLine = Number(hunk[2]);
      return { text, kind: 'hunk', commentable: false };
    }
    if (text.startsWith('\\')) return { text, kind: 'note', commentable: false };
    if (oldLine === null || newLine === null) {
      return { text, kind: diffLineClass(text), commentable: false };
    }
    if (text.startsWith('-')) {
      const row = { text, kind: 'removed', commentable: true, old_line: oldLine, old_position: oldLine };
      oldLine += 1;
      return row;
    }
    if (text.startsWith('+')) {
      const row = { text, kind: 'added', commentable: true, new_line: newLine, new_position: newLine };
      newLine += 1;
      return row;
    }
    const row = {
      text, kind: 'context', commentable: true,
      old_line: oldLine, new_line: newLine, new_position: newLine,
    };
    oldLine += 1;
    newLine += 1;
    return row;
  });
}

function renderDiffFile(file, index, escapeHtml) {
  const panelId = 'review-diff-' + index;
  let preview;
  if (file.diff_available) {
    const lines = parseDiffLines(file.diff_lines).map(row => {
      const oldNumber = row.old_line || row.old_position || '';
      const newNumber = row.new_line || row.new_position || '';
      const content = '<span class="review-line-numbers" aria-hidden="true">' +
        '<span class="review-line-number old">' + oldNumber + '</span>' +
        '<span class="review-line-number new">' + newNumber + '</span></span>' +
        '<span class="review-line-code">' + escapeHtml(row.text) + '</span>';
      if (!row.commentable) {
        return '<span class="review-diff-line ' + row.kind + '">' + content + '</span>';
      }
      const position = row.old_position
        ? ' data-old-position="' + row.old_position + '"'
        : ' data-new-position="' + row.new_position + '"';
      return '<button type="button" class="review-diff-line review-inline-target ' + row.kind +
        '" data-review-filename="' + escapeHtml(file.filename || '') + '"' + position +
        ' aria-label="Comment on ' + escapeHtml(file.filename || 'changed file') + ' line ' +
        (row.old_position || row.new_position) + '">' + content + '</button>';
    }).join('');
    preview = '<pre class="review-diff" id="' + panelId + '" hidden>' + lines +
      (file.diff_truncated ? '<span class="review-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') +
      '</pre>';
  } else {
    const message = file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.';
    preview = '<div class="review-diff-empty" id="' + panelId + '" hidden>' + message +
      (file.diff_truncated ? ' The response was truncated.' : '') + '</div>';
  }
  const filename = escapeHtml(file.filename || 'Unknown file');
  return '<div class="review-file" data-review-filename="' + filename + '"><button class="review-file-toggle" aria-expanded="false" aria-controls="' + panelId + '">' +
    '<strong>' + filename + '</strong><span class="small">' +
    escapeHtml(file.status || 'changed') + ' · +' + Number(file.additions || 0) + ' / −' +
    Number(file.deletions || 0) + '</span></button>' + preview +
    '<label class="small" for="review-note-' + index + '">Note for ' + filename + '</label>' +
    '<textarea class="review-note" id="review-note-' + index + '" data-review-filename="' + filename + '" placeholder="Capture feedback for this file"></textarea>' +
    '<button class="review-mark" data-review-filename="' + filename + '" aria-pressed="false">Mark reviewed</button></div>';
}

function toggleDiff(button, panel) {
  const expanded = button.getAttribute('aria-expanded') === 'true';
  button.setAttribute('aria-expanded', String(!expanded));
  panel.hidden = expanded;
}

function createProgress({ storage, repository, number, headSha, files }) {
  const filenames = (files || []).map(file => file && file.filename).filter(Boolean);
  const key = 'stackchain.review-progress.v1:' + repository + '#' + number + '@' + headSha;
  const headKey = 'stackchain.review-progress.v1:' + repository + '#' + number + ':head';
  let reviewed = [];
  let newHead = false;
  try {
    const previousHead = storage.getItem(headKey);
    newHead = Boolean(previousHead && previousHead !== headSha);
    storage.setItem(headKey, headSha);
    const saved = JSON.parse(storage.getItem(key) || '[]');
    if (Array.isArray(saved)) reviewed = filenames.filter(filename => saved.includes(filename));
  } catch (error) {
    reviewed = [];
  }

  function snapshot() {
    const pending = filenames.find(filename => !reviewed.includes(filename)) || null;
    return {
      reviewed: [...reviewed],
      reviewedCount: reviewed.length,
      total: filenames.length,
      nextFilename: pending,
      ...(newHead ? { newHead: true } : {}),
    };
  }

  function markReviewed(filename) {
    if (filenames.includes(filename) && !reviewed.includes(filename)) {
      reviewed.push(filename);
      reviewed = filenames.filter(item => reviewed.includes(item));
      try { storage.setItem(key, JSON.stringify(reviewed)); } catch (error) { /* local progress remains usable */ }
    }
    return snapshot();
  }

  function clear() {
    reviewed = [];
    newHead = false;
    try { storage.removeItem(key); } catch (error) { /* cleared in memory */ }
    return snapshot();
  }

  return { snapshot, markReviewed, clear, storageKey: key };
}

function createDraft({ storage, repository, number, headSha, files }) {
  const filenames = (files || []).map(file => file && file.filename).filter(Boolean);
  const key = 'stackchain.review-draft.v1:' + repository + '#' + number + '@' + headSha;
  let draft = { notes: {}, comments: [], summary: '', decision: 'comment' };
  try {
    const saved = JSON.parse(storage.getItem(key) || '{}');
    if (saved && typeof saved === 'object') {
      draft.notes = Object.fromEntries(filenames
        .filter(filename => typeof saved.notes?.[filename] === 'string' && saved.notes[filename])
        .map(filename => [filename, saved.notes[filename]]));
      if (Array.isArray(saved.comments)) {
        draft.comments = saved.comments.filter(comment =>
          comment && filenames.includes(comment.path) && typeof comment.body === 'string' &&
          Boolean(comment.body.trim()) &&
          ((Number.isInteger(comment.new_position) && !comment.old_position) ||
           (Number.isInteger(comment.old_position) && !comment.new_position))
        ).map(comment => ({
          path: comment.path, body: comment.body,
          ...(comment.new_position ? { new_position: comment.new_position } : {}),
          ...(comment.old_position ? { old_position: comment.old_position } : {}),
        }));
      }
      if (typeof saved.summary === 'string') draft.summary = saved.summary;
      if (['comment', 'approve', 'request_changes'].includes(saved.decision)) draft.decision = saved.decision;
    }
  } catch (error) { /* start with an empty in-memory draft */ }

  function persist() {
    try { storage.setItem(key, JSON.stringify(draft)); } catch (error) { /* draft remains usable */ }
  }

  function snapshot() {
    return {
      notes: { ...draft.notes }, comments: draft.comments.map(comment => ({ ...comment })),
      summary: draft.summary, decision: draft.decision,
    };
  }

  function setNote(filename, note) {
    if (!filenames.includes(filename)) return snapshot();
    if (String(note)) draft.notes[filename] = String(note);
    else delete draft.notes[filename];
    persist();
    return snapshot();
  }

  function inlineKey(comment) {
    return comment.path + ':' + (comment.new_position ? 'new:' + comment.new_position : 'old:' + comment.old_position);
  }

  function setInlineComment(anchor, body) {
    if (!anchor || !filenames.includes(anchor.path)) return snapshot();
    const position = Number.isInteger(anchor.new_position) && anchor.new_position > 0
      ? { new_position: anchor.new_position }
      : Number.isInteger(anchor.old_position) && anchor.old_position > 0
        ? { old_position: anchor.old_position } : null;
    const text = String(body || '').trim();
    if (!position || !text) return snapshot();
    const comment = { path: anchor.path, body: text, ...position };
    const key = inlineKey(comment);
    const index = draft.comments.findIndex(item => inlineKey(item) === key);
    if (index >= 0) draft.comments[index] = comment;
    else draft.comments.push(comment);
    persist();
    return snapshot();
  }

  function removeInlineComment(anchor) {
    if (!anchor) return snapshot();
    const key = inlineKey(anchor);
    draft.comments = draft.comments.filter(comment => inlineKey(comment) !== key);
    persist();
    return snapshot();
  }

  function setSummary(summary) {
    draft.summary = String(summary || '');
    persist();
    return snapshot();
  }

  function setDecision(decision) {
    if (['comment', 'approve', 'request_changes'].includes(decision)) {
      draft.decision = decision;
      persist();
    }
    return snapshot();
  }

  function clear() {
    draft = { notes: {}, comments: [], summary: '', decision: 'comment' };
    try { storage.removeItem(key); } catch (error) { /* cleared in memory */ }
    return snapshot();
  }

  return {
    snapshot, setNote, setInlineComment, removeInlineComment, setSummary, setDecision, clear,
    storageKey: key,
  };
}

function formatFeedback(draft, files) {
  const decisions = {
    comment: 'Comment',
    approve: 'Approve',
    request_changes: 'Request changes',
  };
  const sections = [
    '## Intended decision\n' + (decisions[draft.decision] || decisions.comment),
  ];
  if (String(draft.summary || '').trim()) {
    sections.push('## Summary\n' + String(draft.summary).trim());
  }
  const fileNotes = (files || []).flatMap(file => {
    const filename = file && file.filename;
    const note = filename && String(draft.notes?.[filename] || '').trim();
    if (!note) return [];
    return ['### `' + String(filename).replaceAll('`', '\\`') + '`\n' + note];
  });
  if (fileNotes.length) sections.push('## File notes\n' + fileNotes.join('\n\n'));
  return sections.join('\n\n');
}

async function copyAndContinue({ text, url, copy, open, fallback }) {
  const destination = open();
  try {
    await copy(text);
  } catch (error) {
    destination?.close?.();
    fallback(text);
    return { copied: false, opened: false };
  }
  if (!destination) {
    fallback(text);
    return { copied: true, opened: false };
  }
  destination.location.href = url;
  return { copied: true, opened: true };
}

function prepareMergeContinuation({ storage, item, headSha, reviewed, decision }) {
  const reasons = Array.isArray(item?.work_reasons) ? item.work_reasons : [];
  if (decision !== 'approve' || !reasons.includes('assigned_to_me') || !headSha) return false;
  const filenames = Array.isArray(reviewed)
    ? reviewed.filter(filename => typeof filename === 'string' && filename)
    : [];
  const key = 'stackchain.pull-review.v1:' + item.repository + '#' + item.number + ':' + headSha;
  try { storage?.setItem(key, JSON.stringify(filenames)); }
  catch (_error) { return false; }
  return true;
}

function renderChecks(checks, escapeHtml, { offline = false } = {}) {
  const rank = { error: 0, failure: 0, pending: 1, warning: 2, unknown: 2, success: 3 };
  const items = (Array.isArray(checks) ? checks : []).filter(check =>
    check && typeof check.name === 'string' && check.name
  ).map((check, index) => ({ ...check, index })).sort((left, right) =>
    (rank[left.state] ?? 2) - (rank[right.state] ?? 2) || left.index - right.index
  );
  const failed = items.filter(check => ['failure', 'error'].includes(check.state)).length;
  const pending = items.filter(check => check.state === 'pending').length;
  const passed = items.filter(check => check.state === 'success').length;
  const other = items.length - failed - pending - passed;
  const parts = [
    failed ? failed + ' failed' : '', pending ? pending + ' pending' : '',
    passed ? passed + ' passed' : '', other ? other + ' other' : '',
  ].filter(Boolean);
  const summary = (offline ? 'Last known · ' : '') + (parts.join(' · ') || 'No checks reported');
  const html = items.map(check => {
    const url = typeof check.url === 'string' ? check.url : '';
    const link = url ? '<a class="ci-check-link" href="' + escapeHtml(url) +
      '" target="_blank" rel="noopener noreferrer">Open job</a>' : '';
    return '<article class="ci-check ci-check-' + escapeHtml(check.state || 'unknown') + '">' +
      '<div class="ci-check-copy"><strong>' + escapeHtml(check.name) + '</strong>' +
      '<span class="small">' + escapeHtml(check.state || 'unknown') +
      (check.description ? ' · ' + escapeHtml(check.description) : '') + '</span></div>' + link + '</article>';
  }).join('');
  return { summary, html, expanded: failed > 0 };
}

createReviewController.renderDiffFile = renderDiffFile;
createReviewController.createWrapPreference = createWrapPreference;
createReviewController.parseDiffLines = parseDiffLines;
createReviewController.toggleDiff = toggleDiff;
createReviewController.createProgress = createProgress;
createReviewController.createDraft = createDraft;
createReviewController.formatFeedback = formatFeedback;
createReviewController.copyAndContinue = copyAndContinue;
createReviewController.prepareMergeContinuation = prepareMergeContinuation;
createReviewController.renderChecks = renderChecks;

if (typeof module !== 'undefined' && module.exports) {
  module.exports = createReviewController;
}
