import { BaseService, type BaseServiceContext } from '@learnitive/base-utils';
import { SERVICE_NAME } from '@learnitive/constants';
import {
  applySourceTextEdit,
  captureSourceAnchor,
  computeMinimalSourceTextEdit,
  resolveSourceAnchor,
  type SourceAnchor,
  type SourceTextEdit,
} from '@learnitive/latex-source';
import type {
  SharedReviewChange,
  SharedReviewChangeMetadata,
  SharedReviewChangeSource,
  SharedReviewComment,
  SharedReviewEvent,
  SharedReviewSnapshot,
} from '@learnitive/project-collaboration';
import { projectFilePath, type ProjectFilePath } from '@learnitive/project-storage';
import { WsPath } from '@learnitive/ws-path';
import { atom } from 'jotai';
import type { LatexSourceService } from './latex-source-service';
import type { ProjectCollaborationService } from './project-collaboration-service';
import type { ProjectSemanticService } from './project-semantic-service';
import type { NoteSnapshotService } from './note-snapshot-service';
import type { ProjectStorageService } from './project-storage-service';
import type { WorkspaceOpsService } from './workspace-ops-service';
import { COLLABORATIVE_REVIEW_BACKEND, LOCAL_REVIEW_BACKEND, type ProjectReviewBackendAdapter } from './project-review-backend';

const REVIEW_DB = 'interactive-review-v1';
const REVIEW_STORE = 'projects';
const REVIEW_ACTIVITY_STORE = 'activity';
const REVIEW_ACTIVITY_MAX = 5000;
const REVIEW_METADATA_KEY = 'learnitiveReview';
const REVIEW_AUTHOR_ID_KEY = 'learnitive:review:author-id';
const REVIEW_AUTHOR_NAME_KEY = 'learnitive:review:author-name';
const TRACK_CHANGES_KEY_PREFIX = 'learnitive:review:track-changes:';
const REVIEW_LEDGER_BEGIN = '% LEARNITIVE-REVIEW-BEGIN v1';
const REVIEW_LEDGER_ENTRY = '% LEARNITIVE-REVIEW ';
const REVIEW_LEDGER_END = '% LEARNITIVE-REVIEW-END';

const reviewPreferenceFallback = new Map<string, string>();

function isLatexReviewSourcePath(filePath: string): boolean {
  return /\.(?:tex|ltx|bib)$/i.test(filePath);
}

function readReviewPreference(key: string): string | null {
  try {
    if (typeof localStorage !== 'undefined') return localStorage.getItem(key);
  } catch {
    // Browser storage can be unavailable in restricted/private contexts.
  }
  return reviewPreferenceFallback.get(key) ?? null;
}

function writeReviewPreference(key: string, value: string): void {
  reviewPreferenceFallback.set(key, value);
  try {
    if (typeof localStorage !== 'undefined') localStorage.setItem(key, value);
  } catch {
    // Keep the in-memory preference for this session when localStorage is blocked.
  }
}

export type ReviewAuthor = {
  id: string;
  displayName: string;
  kind: 'user' | 'ai' | 'system';
  color?: string;
  colorLight?: string;
};

export type ReviewReply = {
  id: string;
  author: ReviewAuthor;
  body: string;
  createdAt: number;
  updatedAt: number;
};

export type ReviewChangeStatus = 'pending' | 'accepted' | 'rejected' | 'outdated' | 'conflicted';
export type ReviewChangeType = 'insert' | 'delete' | 'replace';
export type ReviewChangePresentation = 'prose' | 'semantic' | 'source-only';

export type ReviewChangeMetadata = {
  source?: 'user' | 'ai' | 'system';
  automatic?: boolean;
  category?: string;
  reason?: string;
  confidence?: number;
  provider?: string;
  model?: string;
  operationId?: string;
  reviewedRevision?: string | number;
  findingId?: string;
  applicationMode?: 'applied' | 'deferred';
};

export type ReviewChange = {
  id: string;
  projectId: string;
  filePath: string;
  type: ReviewChangeType;
  presentation: ReviewChangePresentation;
  semanticOperation?: string;
  anchor: SourceAnchor;
  originalText: string;
  proposedText: string;
  author: ReviewAuthor;
  status: ReviewChangeStatus;
  metadata?: ReviewChangeMetadata;
  createdAt: number;
  updatedAt: number;
};

export type ReviewThread = {
  id: string;
  projectId: string;
  kind: 'comment' | 'suggestion' | 'change';
  scope: 'anchor' | 'document' | 'project';
  anchor?: SourceAnchor;
  author: ReviewAuthor;
  body: string;
  status: 'open' | 'resolved' | 'outdated';
  createdAt: number;
  updatedAt: number;
  replies: ReviewReply[];
  change?: ReviewChange;
};

export type ProposeReviewChangeInput = {
  filePath: string;
  from: number;
  to: number;
  originalText: string;
  proposedText: string;
  sourceRevision?: number;
  sourceAnchor?: SourceAnchor;
  semanticKind?: string;
  author?: ReviewAuthor;
  metadata?: ReviewChangeMetadata;
};

export type CreateReviewCommentInput = {
  body: string;
  scope?: ReviewThread['scope'];
  anchor?: SourceAnchor;
  author?: ReviewAuthor;
};

export type ReviewThreadFilter = {
  status?: 'open' | 'resolved' | 'all';
  file?: string;
  mine?: boolean;
  outdated?: boolean;
};

export type ReviewChangeFilter = {
  status?: ReviewChangeStatus | 'all';
  file?: string;
  authorId?: string;
};

export type ReviewActivityKind =
  | 'comment-created'
  | 'comment-replied'
  | 'comment-edited'
  | 'comment-deleted'
  | 'comment-resolved'
  | 'comment-reopened'
  | 'change-proposed'
  | 'change-accepted'
  | 'change-rejected'
  | 'change-outdated'
  | 'change-conflicted'
  | 'snapshot-created';

export type ReviewActivity = {
  id: string;
  projectId: string;
  kind: ReviewActivityKind;
  createdAt: number;
  author: ReviewAuthor;
  summary: string;
  threadId?: string;
  filePath?: string;
  changeType?: ReviewChangeType;
  source?: 'user' | 'ai' | 'system';
  metadata?: Record<string, string | number | boolean>;
};

export type ReviewActivityFilter = {
  file?: string;
  authorId?: string;
  kinds?: readonly ReviewActivityKind[];
  since?: number;
  until?: number;
};

type WorkspaceReviewState = {
  projectId?: string;
  threads: ReviewThread[];
  loaded: boolean;
  trackChanges: boolean;
  trackPreferenceLoaded: boolean;
  activity: ReviewActivity[];
  activityLoaded: boolean;
  legacyLedgerChecked: boolean;
  backend: ProjectReviewBackendAdapter;
  localThreads?: ReviewThread[];
  localActivity?: ReviewActivity[];
  sharedCleanup?: () => void;
};

export type ReviewProjectionSegment = {
  kind: 'canonical' | 'change';
  sourceFrom: number;
  sourceTo: number;
  projectedFrom: number;
  projectedTo: number;
  changeId?: string;
};

export type ReviewProjection = {
  text: string;
  segments: readonly ReviewProjectionSegment[];
};

export type ReviewEditorDecoration = {
  id: string;
  from: number;
  to: number;
  status: 'open' | 'resolved' | 'outdated';
  kind: 'comment' | 'change';
  changeType?: ReviewChangeType;
  originalText?: string;
  proposedText?: string;
  authorName?: string;
  authorColor?: string;
  conflicted?: boolean;
  automatic?: boolean;
};

function randomId(prefix: string): string {
  return `${prefix}-${crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
}

function isReviewMetadata(value: unknown): value is { version: 1; id: string } {
  return Boolean(value && typeof value === 'object' && (value as any).version === 1 && typeof (value as any).id === 'string');
}

function isReviewAuthor(value: unknown): value is ReviewAuthor {
  return Boolean(value && typeof value === 'object' && typeof (value as any).id === 'string' && typeof (value as any).displayName === 'string' && ((value as any).kind === 'user' || (value as any).kind === 'ai' || (value as any).kind === 'system'));
}

function isSourceAnchor(value: unknown): value is SourceAnchor {
  return Boolean(value && typeof value === 'object' && typeof (value as any).filePath === 'string' && Number.isInteger((value as any).from) && Number.isInteger((value as any).to));
}

function isReviewChange(value: unknown): value is ReviewChange {
  if (!value || typeof value !== 'object') return false;
  const change = value as Partial<ReviewChange>;
  return typeof change.id === 'string'
    && typeof change.projectId === 'string'
    && typeof change.filePath === 'string'
    && ['insert', 'delete', 'replace'].includes(String(change.type))
    && ['prose', 'semantic', 'source-only'].includes(String(change.presentation))
    && isSourceAnchor(change.anchor)
    && typeof change.originalText === 'string'
    && typeof change.proposedText === 'string'
    && isReviewAuthor(change.author)
    && ['pending', 'accepted', 'rejected', 'outdated', 'conflicted'].includes(String(change.status))
    && (change.metadata === undefined || (typeof change.metadata === 'object' && change.metadata !== null && !Array.isArray(change.metadata)))
    && typeof change.createdAt === 'number'
    && typeof change.updatedAt === 'number';
}

function isReviewThread(value: unknown): value is ReviewThread {
  if (!value || typeof value !== 'object') return false;
  const thread = value as Partial<ReviewThread>;
  return typeof thread.id === 'string'
    && typeof thread.projectId === 'string'
    && ['comment', 'suggestion', 'change'].includes(String(thread.kind))
    && ['anchor', 'document', 'project'].includes(String(thread.scope))
    && isReviewAuthor(thread.author)
    && typeof thread.body === 'string'
    && ['open', 'resolved', 'outdated'].includes(String(thread.status))
    && typeof thread.createdAt === 'number'
    && typeof thread.updatedAt === 'number'
    && Array.isArray(thread.replies)
    && (thread.kind !== 'change' || isReviewChange(thread.change));
}

function isReviewActivity(value: unknown): value is ReviewActivity {
  if (!value || typeof value !== 'object') return false;
  const item = value as Partial<ReviewActivity>;
  return typeof item.id === 'string'
    && typeof item.projectId === 'string'
    && [
      'comment-created', 'comment-replied', 'comment-edited', 'comment-deleted',
      'comment-resolved', 'comment-reopened', 'change-proposed', 'change-accepted',
      'change-rejected', 'change-outdated', 'change-conflicted', 'snapshot-created',
    ].includes(String(item.kind))
    && typeof item.createdAt === 'number'
    && isReviewAuthor(item.author)
    && typeof item.summary === 'string';
}


function portableAnchor(anchor: SourceAnchor): SourceAnchor {
  const portable = { ...anchor };
  delete portable.sourceRevision;
  return portable;
}

function portableReviewThread(thread: ReviewThread, projectId: string): ReviewThread {
  const anchor = thread.anchor ? portableAnchor(thread.anchor) : undefined;
  const change = thread.change
    ? {
        ...thread.change,
        projectId,
        anchor: portableAnchor(thread.change.anchor),
      }
    : undefined;
  return {
    ...thread,
    projectId,
    ...(anchor ? { anchor } : {}),
    ...(change ? { change } : {}),
  };
}

/** Parse a legacy BF4.3/BF4.4 source ledger for one-time cleanup/migration only. */
function parseLegacyReviewLedger(source: string, projectId: string): ReviewThread[] {
  const begin = source.lastIndexOf(REVIEW_LEDGER_BEGIN);
  if (begin < 0) return [];
  const end = source.indexOf(REVIEW_LEDGER_END, begin + REVIEW_LEDGER_BEGIN.length);
  if (end < 0) return [];
  const body = source.slice(begin + REVIEW_LEDGER_BEGIN.length, end);
  const threads: ReviewThread[] = [];
  for (const line of body.split(/\r?\n/u)) {
    if (!line.startsWith(REVIEW_LEDGER_ENTRY)) continue;
    const raw = line.slice(REVIEW_LEDGER_ENTRY.length).trim();
    if (!raw) continue;
    try {
      const parsed: unknown = JSON.parse(raw);
      if (!isReviewThread(parsed)) continue;
      threads.push(portableReviewThread(parsed, projectId));
    } catch {
      // A damaged review metadata line must never make the LaTeX document unusable.
    }
  }
  return threads;
}

/** Remove legacy BF4.3/BF4.4 source-ledger metadata. Review state is local/collaborative only. */
function stripLegacyReviewLedger(source: string): string {
  let next = source;
  while (true) {
    const begin = next.lastIndexOf(REVIEW_LEDGER_BEGIN);
    if (begin < 0) return next;
    const end = next.indexOf(REVIEW_LEDGER_END, begin + REVIEW_LEDGER_BEGIN.length);
    if (end < 0) return next;
    let removalEnd = end + REVIEW_LEDGER_END.length;
    if (next.startsWith('\r\n', removalEnd)) removalEnd += 2;
    else if (next.startsWith('\n', removalEnd)) removalEnd += 1;
    next = next.slice(0, begin) + next.slice(removalEnd);
  }
}

function mergeReviewThreads(local: readonly ReviewThread[], portable: readonly ReviewThread[], projectId: string): ReviewThread[] {
  const merged = new Map<string, ReviewThread>();
  for (const candidate of [...local, ...portable]) {
    const thread = portableReviewThread(candidate, projectId);
    const current = merged.get(thread.id);
    if (!current || thread.updatedAt > current.updatedAt) merged.set(thread.id, thread);
  }
  return [...merged.values()];
}

async function openReviewDb(): Promise<IDBDatabase> {
  return await new Promise((resolve, reject) => {
    const request = indexedDB.open(REVIEW_DB, 2);
    request.onupgradeneeded = () => {
      const db = request.result;
      if (!db.objectStoreNames.contains(REVIEW_STORE)) db.createObjectStore(REVIEW_STORE);
      if (!db.objectStoreNames.contains(REVIEW_ACTIVITY_STORE)) db.createObjectStore(REVIEW_ACTIVITY_STORE);
    };
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error ?? new Error('Unable to open review database'));
  });
}

async function readReviewThreads(projectId: string): Promise<ReviewThread[]> {
  if (typeof indexedDB === 'undefined') return [];
  const db = await openReviewDb();
  try {
    return await new Promise((resolve, reject) => {
      const tx = db.transaction(REVIEW_STORE, 'readonly');
      const request = tx.objectStore(REVIEW_STORE).get(projectId);
      request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result.filter(isReviewThread) : []);
      request.onerror = () => reject(request.error ?? new Error('Unable to read review threads'));
    });
  } finally {
    db.close();
  }
}

async function writeReviewThreads(projectId: string, threads: readonly ReviewThread[]): Promise<void> {
  if (typeof indexedDB === 'undefined') return;
  const db = await openReviewDb();
  try {
    await new Promise<void>((resolve, reject) => {
      const tx = db.transaction(REVIEW_STORE, 'readwrite');
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error ?? new Error('Unable to persist review threads'));
      tx.objectStore(REVIEW_STORE).put(threads.map((thread) => structuredClone(thread)), projectId);
    });
  } finally {
    db.close();
  }
}

async function readReviewActivity(projectId: string): Promise<ReviewActivity[]> {
  if (typeof indexedDB === 'undefined') return [];
  const db = await openReviewDb();
  try {
    return await new Promise((resolve, reject) => {
      const tx = db.transaction(REVIEW_ACTIVITY_STORE, 'readonly');
      const request = tx.objectStore(REVIEW_ACTIVITY_STORE).get(projectId);
      request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result.filter(isReviewActivity) : []);
      request.onerror = () => reject(request.error ?? new Error('Unable to read review activity'));
    });
  } finally {
    db.close();
  }
}

async function writeReviewActivity(projectId: string, activity: readonly ReviewActivity[]): Promise<void> {
  if (typeof indexedDB === 'undefined') return;
  const db = await openReviewDb();
  try {
    await new Promise<void>((resolve, reject) => {
      const tx = db.transaction(REVIEW_ACTIVITY_STORE, 'readwrite');
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error ?? new Error('Unable to persist review activity'));
      tx.objectStore(REVIEW_ACTIVITY_STORE).put(activity.slice(-REVIEW_ACTIVITY_MAX).map((item) => structuredClone(item)), projectId);
    });
  } finally {
    db.close();
  }
}

function bootstrapReviewActivity(projectId: string, threads: readonly ReviewThread[]): ReviewActivity[] {
  const activity: ReviewActivity[] = [];
  for (const thread of threads) {
    const filePath = thread.anchor?.filePath;
    if (thread.change) {
      activity.push({
        id: `legacy:${thread.id}:proposed`,
        projectId,
        kind: 'change-proposed',
        createdAt: thread.change.createdAt,
        author: thread.change.author,
        summary: `${thread.change.author.kind === 'ai' ? 'AI suggested' : 'Proposed'} ${thread.change.type} in ${thread.change.filePath}`,
        threadId: thread.id,
        filePath: thread.change.filePath,
        changeType: thread.change.type,
        source: thread.change.author.kind === 'ai' ? 'ai' : 'user',
      });
      if (thread.change.status === 'accepted' || thread.change.status === 'rejected' || thread.change.status === 'outdated' || thread.change.status === 'conflicted') {
        activity.push({
          id: `legacy:${thread.id}:${thread.change.status}`,
          projectId,
          kind: thread.change.status === 'accepted' ? 'change-accepted' : thread.change.status === 'rejected' ? 'change-rejected' : thread.change.status === 'conflicted' ? 'change-conflicted' : 'change-outdated',
          createdAt: thread.change.updatedAt,
          author: thread.change.author,
          summary: `${thread.change.status[0]!.toUpperCase()}${thread.change.status.slice(1)} ${thread.change.type} in ${thread.change.filePath}`,
          threadId: thread.id,
          filePath: thread.change.filePath,
          changeType: thread.change.type,
          source: thread.change.author.kind === 'ai' ? 'ai' : 'user',
        });
      }
    } else {
      activity.push({
        id: `legacy:${thread.id}:created`,
        projectId,
        kind: 'comment-created',
        createdAt: thread.createdAt,
        author: thread.author,
        summary: `${thread.scope === 'project' ? 'Project' : thread.scope === 'document' ? 'Document' : 'Source'} comment created`,
        threadId: thread.id,
        ...(filePath ? { filePath } : {}),
        source: thread.author.kind === 'ai' ? 'ai' : 'user',
      });
      if (thread.status === 'resolved') activity.push({ id: `legacy:${thread.id}:resolved`, projectId, kind: 'comment-resolved', createdAt: thread.updatedAt, author: thread.author, summary: 'Comment resolved', threadId: thread.id, ...(filePath ? { filePath } : {}), source: 'user' });
      if (thread.status === 'outdated') activity.push({ id: `legacy:${thread.id}:outdated`, projectId, kind: 'change-outdated', createdAt: thread.updatedAt, author: { id: 'interactive-system', displayName: 'Learnitive', kind: 'system' }, summary: 'Review anchor became outdated', threadId: thread.id, ...(filePath ? { filePath } : {}), source: 'system' });
    }
    for (const reply of thread.replies) {
      activity.push({ id: `legacy:${thread.id}:reply:${reply.id}`, projectId, kind: 'comment-replied', createdAt: reply.createdAt, author: reply.author, summary: 'Replied to review thread', threadId: thread.id, ...(filePath ? { filePath } : {}), source: reply.author.kind === 'ai' ? 'ai' : 'user' });
    }
  }
  return activity.sort((a, b) => a.createdAt - b.createdAt).slice(-REVIEW_ACTIVITY_MAX);
}

function changeType(originalText: string, proposedText: string): ReviewChangeType {
  if (!originalText) return 'insert';
  if (!proposedText) return 'delete';
  return 'replace';
}

function inferSemanticKind(source: string, from: number): string | undefined {
  const before = source.slice(0, Math.max(0, from));
  const insideEnvironment = (names: readonly string[]) => {
    for (const name of names) {
      const open = before.lastIndexOf(`\\begin{${name}}`);
      const close = before.lastIndexOf(`\\end{${name}}`);
      if (open > close) return name;
    }
    return undefined;
  };
  const table = insideEnvironment(['tabular', 'tabularx', 'longtable', 'table', 'table*']);
  if (table) return 'table';
  const figure = insideEnvironment(['figure', 'figure*', 'subfigure']);
  if (figure) return 'figure';
  const equation = insideEnvironment(['equation', 'equation*', 'align', 'align*', 'gather', 'multline', 'displaymath']);
  if (equation) return 'equation';
  const lineStart = before.lastIndexOf('\n') + 1;
  const near = source.slice(Math.max(lineStart, from - 160), Math.min(source.length, from + 240));
  if (/\\(?:cite|citep|citet|autocite|parencite|textcite)\s*(?:\[[^\]]*\])?\s*\{/u.test(near)) return 'citation';
  if (/\\(?:section|subsection|subsubsection|chapter|paragraph)\s*\{/u.test(near)) return 'heading';
  if (/\$[^$]*$/u.test(before.slice(Math.max(0, before.length - 300))) || /\\\\?\[?[^\n]*$/u.test(before.slice(Math.max(0, before.length - 200)))) return 'equation';
  return undefined;
}

function changePresentation(source: string, from: number, originalText: string, proposedText: string, semanticKind?: string): ReviewChangePresentation {
  const kind = semanticKind?.toLowerCase() ?? '';
  if (/math|equation|table|figure|citation|reference|heading|section|list|theorem/u.test(kind)) return 'semantic';
  const sample = `${originalText}\n${proposedText}`;
  if (/\\(?:begin\{(?:equation|align|gather|multline|table|tabular|figure)|cite|ref|includegraphics)\b/u.test(sample) || /\$[^$]*\$/u.test(sample)) return 'semantic';
  const documentStart = source.indexOf('\\begin{document}');
  if ((documentStart < 0 || from < documentStart) && /\\[A-Za-z@]+/u.test(sample)) return 'source-only';
  return 'prose';
}

function inferSemanticOperation(type: ReviewChangeType, semanticKind: string | undefined, originalText: string, proposedText: string): string | undefined {
  const kind = semanticKind?.toLowerCase() ?? '';
  const inserted = type === 'insert';
  const deleted = type === 'delete';
  if (/math|equation/u.test(kind)) return inserted ? 'Equation inserted' : deleted ? 'Equation deleted' : 'Equation changed';
  if (/citation/u.test(kind)) return inserted ? 'Citation added' : deleted ? 'Citation removed' : 'Citation keys replaced';
  if (/figure/u.test(kind)) {
    if (inserted) return 'Figure inserted';
    if (deleted) return 'Figure deleted';
    if (/\\includegraphics/u.test(originalText) || /\\includegraphics/u.test(proposedText)) return 'Figure image/settings changed';
    if (/\\caption/u.test(originalText) || /\\caption/u.test(proposedText)) return 'Figure caption changed';
    if (/\\label/u.test(originalText) || /\\label/u.test(proposedText)) return 'Figure label changed';
    return 'Figure changed';
  }
  if (/table/u.test(kind)) {
    if (inserted && /\\\\/u.test(proposedText)) return 'Table row inserted';
    if (deleted && /\\\\/u.test(originalText)) return 'Table row deleted';
    if (/\\caption/u.test(originalText) || /\\caption/u.test(proposedText)) return 'Table caption changed';
    if (/\\label/u.test(originalText) || /\\label/u.test(proposedText)) return 'Table label changed';
    if (/\\begin\{tabular/u.test(originalText) || /\\begin\{tabular/u.test(proposedText)) return 'Table alignment/structure changed';
    return inserted ? 'Table content inserted' : deleted ? 'Table content deleted' : 'Table cell/content changed';
  }
  if (/heading|section/u.test(kind)) return 'Heading changed';
  if (/list/u.test(kind)) return 'List changed';
  if (/theorem/u.test(kind)) return 'Theorem block changed';
  return undefined;
}

function changeBody(change: Pick<ReviewChange, 'type' | 'presentation' | 'filePath' | 'semanticOperation'>): string {
  const label = change.type === 'insert' ? 'Insertion' : change.type === 'delete' ? 'Deletion' : 'Replacement';
  const presentation = change.presentation === 'source-only' ? 'Source-only change' : change.semanticOperation ?? (change.presentation === 'semantic' ? 'Semantic change' : 'Prose change');
  return `${label} · ${presentation} · ${change.filePath}`;
}

function sharedAuthor(author: SharedReviewComment['author'], kind: ReviewAuthor['kind'] = 'user'): ReviewAuthor {
  return {
    id: author.id,
    displayName: author.displayName,
    kind,
    ...(author.color ? { color: author.color } : {}),
    ...(author.colorLight ? { colorLight: author.colorLight } : {}),
  };
}

function sharedAnchor(
  filePath: string,
  from: number,
  to: number,
  source: string,
  quotedText?: string,
  semanticKind?: string,
): SourceAnchor {
  const safeFrom = Math.max(0, Math.min(Math.trunc(from), source.length));
  const safeTo = Math.max(safeFrom, Math.min(Math.trunc(to), source.length));
  const anchor = captureSourceAnchor({ filePath, source, from: safeFrom, to: safeTo, ...(semanticKind ? { semanticKind } : {}) });
  return quotedText !== undefined ? { ...anchor, selectedText: quotedText } : anchor;
}

function mapSharedReviewSnapshot(
  projectId: string,
  snapshot: SharedReviewSnapshot,
  getSource: (filePath: string) => string | undefined,
): { threads: ReviewThread[]; activity: ReviewActivity[] } {
  const threads: ReviewThread[] = [];
  for (const comment of snapshot.comments) {
    const source = comment.filePath ? getSource(comment.filePath) : undefined;
    const anchor = comment.filePath && source !== undefined && comment.from !== undefined && comment.to !== undefined
      ? sharedAnchor(comment.filePath, comment.from, comment.to, source, comment.quotedText)
      : undefined;
    threads.push({
      id: comment.id,
      projectId,
      kind: 'comment',
      scope: comment.scope,
      ...(anchor ? { anchor } : {}),
      author: sharedAuthor(comment.author),
      body: comment.body,
      status: comment.anchorRemoved || comment.status === 'outdated' ? 'outdated' : comment.status,
      createdAt: comment.createdAt,
      updatedAt: comment.updatedAt,
      replies: comment.replies.map((reply) => ({ ...reply, author: sharedAuthor(reply.author) })),
    });
  }
  for (const item of snapshot.changes) {
    const source = getSource(item.filePath) ?? '';
    const from = item.from ?? Math.min(source.length, 0);
    const to = item.to ?? from;
    const anchor = sharedAnchor(item.filePath, from, to, source, item.originalText || item.proposedText, item.semanticKind);
    const presentation = changePresentation(source, from, item.originalText, item.proposedText, item.semanticKind);
    const semanticOperation = inferSemanticOperation(item.type, item.semanticKind, item.originalText, item.proposedText);
    const author = sharedAuthor(item.author, item.source === 'ai' ? 'ai' : item.source === 'system' ? 'system' : 'user');
    const change: ReviewChange = {
      id: item.id,
      projectId,
      filePath: item.filePath,
      type: item.type,
      presentation,
      ...(semanticOperation ? { semanticOperation } : {}),
      anchor,
      originalText: item.originalText,
      proposedText: item.proposedText,
      author,
      status: item.anchorRemoved && item.state === 'pending' ? 'conflicted' : item.state,
      metadata: {
        source: item.source === 'ai' ? 'ai' : item.source === 'system' ? 'system' : 'user',
        applicationMode: item.applicationMode,
        ...(item.semanticKind ? { category: item.semanticKind } : {}),
        ...(item.operationId ? { operationId: item.operationId } : {}),
        ...(item.metadata?.automatic !== undefined ? { automatic: item.metadata.automatic } : {}),
        ...(item.metadata?.category ? { category: item.metadata.category } : {}),
        ...(item.metadata?.reason ? { reason: item.metadata.reason } : {}),
        ...(item.metadata?.confidence !== undefined ? { confidence: item.metadata.confidence } : {}),
        ...(item.metadata?.provider ? { provider: item.metadata.provider } : {}),
        ...(item.metadata?.model ? { model: item.metadata.model } : {}),
        ...(item.metadata?.reviewedRevision !== undefined ? { reviewedRevision: item.metadata.reviewedRevision } : {}),
        ...(item.metadata?.findingId ? { findingId: item.metadata.findingId } : {}),
      },
      createdAt: item.createdAt,
      updatedAt: item.updatedAt,
    };
    threads.push({
      id: item.id,
      projectId,
      kind: 'change',
      scope: 'anchor',
      anchor,
      author,
      body: changeBody(change),
      status: change.status === 'pending' || change.status === 'conflicted' ? 'open' : 'resolved',
      createdAt: item.createdAt,
      updatedAt: item.updatedAt,
      replies: item.replies.map((reply) => ({ ...reply, author: sharedAuthor(reply.author) })),
      change,
    });
  }
  const activity: ReviewActivity[] = snapshot.events.map((event: SharedReviewEvent) => ({
    id: event.id,
    projectId,
    kind: event.kind,
    createdAt: event.createdAt,
    author: sharedAuthor(event.author, event.source === 'ai' ? 'ai' : event.source === 'system' ? 'system' : 'user'),
    summary: event.summary,
    ...(event.threadId ? { threadId: event.threadId } : {}),
    ...(event.filePath ? { filePath: event.filePath } : {}),
    ...(event.changeType ? { changeType: event.changeType } : {}),
    source: event.source === 'ai' ? 'ai' : event.source === 'system' ? 'system' : 'user',
    ...((event.source || event.semanticKind || event.operationId) ? { metadata: {
      ...(event.source ? { source: event.source } : {}),
      ...(event.semanticKind ? { semanticKind: event.semanticKind } : {}),
      ...(event.operationId ? { operationId: event.operationId } : {}),
    } } : {}),
  }));
  return { threads, activity };
}

export function buildReviewProjection(canonical: string, changes: readonly ReviewChange[]): ReviewProjection {
  const pending = changes
    .filter((change) => change.status === 'pending')
    .sort((a, b) => a.anchor.from - b.anchor.from || a.anchor.to - b.anchor.to || a.createdAt - b.createdAt);
  const segments: ReviewProjectionSegment[] = [];
  let sourceAt = 0;
  let projectedAt = 0;
  let text = '';
  for (const change of pending) {
    const rawFrom = Math.max(0, Math.min(change.anchor.from, canonical.length));
    const from = rawFrom;
    const to = Math.max(from, Math.min(change.anchor.to, canonical.length));
    if (from < sourceAt) continue;
    if (from > sourceAt) {
      const part = canonical.slice(sourceAt, from);
      text += part;
      segments.push({ kind: 'canonical', sourceFrom: sourceAt, sourceTo: from, projectedFrom: projectedAt, projectedTo: projectedAt + part.length });
      projectedAt += part.length;
    }
    const proposed = change.proposedText;
    text += proposed;
    segments.push({ kind: 'change', sourceFrom: from, sourceTo: to, projectedFrom: projectedAt, projectedTo: projectedAt + proposed.length, changeId: change.id });
    projectedAt += proposed.length;
    sourceAt = to;
  }
  if (sourceAt < canonical.length) {
    const part = canonical.slice(sourceAt);
    text += part;
    segments.push({ kind: 'canonical', sourceFrom: sourceAt, sourceTo: canonical.length, projectedFrom: projectedAt, projectedTo: projectedAt + part.length });
  }
  if (canonical.length === 0 && segments.length === 0) segments.push({ kind: 'canonical', sourceFrom: 0, sourceTo: 0, projectedFrom: 0, projectedTo: 0 });
  return { text, segments };
}

function sourceBoundaryForProjected(projection: ReviewProjection, position: number, bias: 'left' | 'right'): number {
  const pos = Math.max(0, Math.min(position, projection.text.length));
  for (const segment of projection.segments) {
    if (segment.kind === 'canonical' && pos >= segment.projectedFrom && pos <= segment.projectedTo) {
      return segment.sourceFrom + Math.min(segment.sourceTo - segment.sourceFrom, Math.max(0, pos - segment.projectedFrom));
    }
    if (segment.kind === 'change') {
      if (segment.projectedFrom === segment.projectedTo && pos === segment.projectedFrom) return bias === 'left' ? segment.sourceFrom : segment.sourceTo;
      if (pos >= segment.projectedFrom && pos <= segment.projectedTo) return bias === 'left' ? segment.sourceFrom : segment.sourceTo;
    }
  }
  const last = projection.segments.at(-1);
  return last?.sourceTo ?? 0;
}

function projectedBoundaryForSource(projection: ReviewProjection, sourcePosition: number, bias: 'left' | 'right'): number {
  const pos = Math.max(0, sourcePosition);
  for (const segment of projection.segments) {
    if (segment.kind === 'canonical' && pos >= segment.sourceFrom && pos <= segment.sourceTo) {
      return segment.projectedFrom + Math.min(segment.projectedTo - segment.projectedFrom, Math.max(0, pos - segment.sourceFrom));
    }
    if (segment.kind === 'change' && pos >= segment.sourceFrom && pos <= segment.sourceTo) {
      return bias === 'left' ? segment.projectedFrom : segment.projectedTo;
    }
  }
  return projection.text.length;
}

function segmentAtProjected(projection: ReviewProjection, position: number): ReviewProjectionSegment | undefined {
  return projection.segments.find((segment) => segment.kind === 'change' && segment.projectedTo > segment.projectedFrom && position >= segment.projectedFrom && position <= segment.projectedTo);
}

/** Browser-local review repository shared by comments, Track Changes and future AI suggestions. */
export class ProjectReviewService extends BaseService {
  static deps = ['latexSource', 'noteSnapshot', 'projectCollaboration', 'projectSemantic', 'projectStorage', 'workspaceOps'] as const;
  readonly $version = atom(0);
  private readonly workspaces = new Map<string, WorkspaceReviewState>();

  constructor(
    context: BaseServiceContext,
    private readonly dependencies: {
      latexSource: LatexSourceService;
      noteSnapshot: NoteSnapshotService;
      projectCollaboration: ProjectCollaborationService;
      projectSemantic: ProjectSemanticService;
      projectStorage: ProjectStorageService;
      workspaceOps: WorkspaceOpsService;
    },
  ) {
    super(SERVICE_NAME.projectReviewService, context, dependencies);
  }

  async hookMount(): Promise<void> {
    const unsubSource = this.dependencies.latexSource.subscribeToSaveStatus(() => {
      for (const wsName of this.workspaces.keys()) void this.recoverAnchors(wsName).then(() => this.bump());
    });
    this.addCleanup(unsubSource);
    this.addCleanup(() => {
      for (const state of this.workspaces.values()) state.sharedCleanup?.();
      this.workspaces.clear();
    });
  }

  private async readReviewSource(wsName: string, filePath: string): Promise<{ text: string; sourceRevision?: number }> {
    const wsPath = WsPath.fromParts(wsName, filePath).wsPath;
    if (isLatexReviewSourcePath(filePath)) {
      let snapshot = this.dependencies.latexSource.getSnapshot(wsPath);
      if (!snapshot) {
        await this.dependencies.latexSource.load(wsPath);
        snapshot = this.dependencies.latexSource.getSnapshot(wsPath);
      }
      if (snapshot) return { text: snapshot.text, sourceRevision: snapshot.revision };
    }
    const provider = await this.dependencies.projectStorage.getProvider(wsName);
    const bytes = await provider.readFile(projectFilePath(filePath));
    if (!bytes) throw new Error(`Unable to load source for ${filePath}`);
    return { text: new TextDecoder('utf-8', { fatal: false }).decode(bytes) };
  }

  private async writeReviewSource(wsName: string, filePath: string, text: string): Promise<void> {
    const wsPath = WsPath.fromParts(wsName, filePath).wsPath;
    if (isLatexReviewSourcePath(filePath)) {
      await this.dependencies.latexSource.load(wsPath);
      const snapshot = this.dependencies.latexSource.getSnapshot(wsPath);
      if (!snapshot) throw new Error('Source file is not available');
      this.dependencies.latexSource.update(wsPath, text, 'review');
      return;
    }
    const provider = await this.dependencies.projectStorage.getProvider(wsName);
    await provider.writeFile(projectFilePath(filePath), new TextEncoder().encode(text));
  }

  getLocalAuthor(wsName?: string): ReviewAuthor {
    if (wsName && this.dependencies.projectCollaboration.isActive(wsName)) {
      const user = this.dependencies.projectCollaboration.getLocalCollaborationUser(wsName);
      if (user) return { id: user.id, displayName: user.name, kind: 'user', color: user.color, colorLight: user.colorLight };
    }
    let id = readReviewPreference(REVIEW_AUTHOR_ID_KEY);
    if (!id) {
      id = randomId('review-user');
      writeReviewPreference(REVIEW_AUTHOR_ID_KEY, id);
    }
    const displayName = readReviewPreference(REVIEW_AUTHOR_NAME_KEY) || 'Local reviewer';
    return { id, displayName, kind: 'user' };
  }

  setLocalDisplayName(displayName: string): void {
    const normalized = displayName.trim().slice(0, 80);
    if (!normalized) return;
    writeReviewPreference(REVIEW_AUTHOR_NAME_KEY, normalized);
    this.bump();
  }

  async refresh(wsName: string): Promise<readonly ReviewThread[]> {
    const state = this.state(wsName);
    if (!state.projectId) state.projectId = await this.ensureProjectId(wsName);
    if (!state.loaded) {
      state.threads = await readReviewThreads(state.projectId);
      state.loaded = true;
    }
    if (!state.legacyLedgerChecked) {
      const legacy = await this.consumeLegacySourceReviewLedger(wsName, state.projectId);
      state.threads = mergeReviewThreads(state.threads, legacy, state.projectId);
      state.legacyLedgerChecked = true;
      await writeReviewThreads(state.projectId, state.threads);
    }
    if (!state.activityLoaded) {
      state.activity = await readReviewActivity(state.projectId);
      if (state.activity.length === 0 && state.threads.length > 0) {
        state.activity = bootstrapReviewActivity(state.projectId, state.threads);
        await writeReviewActivity(state.projectId, state.activity);
      }
      state.activityLoaded = true;
    }
    if (!state.trackPreferenceLoaded) {
      state.trackChanges = readReviewPreference(`${TRACK_CHANGES_KEY_PREFIX}${state.projectId}`) === 'on';
      state.trackPreferenceLoaded = true;
    }

    if (this.dependencies.projectCollaboration.isActive(wsName)) {
      if (state.backend.kind !== 'collaboration') {
        state.localThreads = state.threads.map((thread) => structuredClone(thread));
        state.localActivity = state.activity.map((item) => structuredClone(item));
        state.backend = COLLABORATIVE_REVIEW_BACKEND;
        state.sharedCleanup?.();
        state.sharedCleanup = this.dependencies.projectCollaboration.subscribeSharedReview(wsName, () => {
          this.syncSharedReview(wsName);
          this.bump();
        });
      }
      this.dependencies.projectCollaboration.setReviewTrackChangesEnabled(wsName, state.trackChanges);
      this.syncSharedReview(wsName);
      this.bump();
      return state.threads;
    }

    if (state.backend.kind === 'collaboration') {
      const sharedThreads = state.threads.map((thread) => structuredClone(thread));
      const sharedActivity = state.activity.map((item) => structuredClone(item));
      state.sharedCleanup?.();
      state.sharedCleanup = undefined;
      state.threads = mergeReviewThreads(state.localThreads ?? [], sharedThreads, state.projectId);
      const activities = new Map<string, ReviewActivity>();
      for (const item of [...(state.localActivity ?? []), ...sharedActivity]) {
        const current = activities.get(item.id);
        if (!current || item.createdAt >= current.createdAt) activities.set(item.id, item);
      }
      state.activity = [...activities.values()].sort((a, b) => a.createdAt - b.createdAt).slice(-REVIEW_ACTIVITY_MAX);
      state.localThreads = undefined;
      state.localActivity = undefined;
      state.backend = LOCAL_REVIEW_BACKEND;
      await writeReviewThreads(state.projectId, state.threads);
      await writeReviewActivity(state.projectId, state.activity);
    }

    await this.recoverAnchors(wsName);
    this.bump();
    return state.threads;
  }

  getTrackChangesEnabled(wsName: string): boolean {
    const enabled = this.state(wsName).trackChanges;
    if (this.dependencies.projectCollaboration.isActive(wsName)) this.dependencies.projectCollaboration.setReviewTrackChangesEnabled(wsName, enabled);
    return enabled;
  }

  async setTrackChangesEnabled(wsName: string, enabled: boolean): Promise<void> {
    await this.refresh(wsName);
    const state = this.state(wsName);
    state.trackChanges = enabled;
    state.trackPreferenceLoaded = true;
    writeReviewPreference(`${TRACK_CHANGES_KEY_PREFIX}${state.projectId!}`, enabled ? 'on' : 'off');
    if (this.dependencies.projectCollaboration.isActive(wsName)) {
      this.dependencies.projectCollaboration.setReviewTrackChangesEnabled(wsName, enabled);
      if (!enabled) this.dependencies.projectCollaboration.stopReviewChangeCoalescing(wsName);
    }
    this.bump();
  }

  getThreads(wsName: string, filter: ReviewThreadFilter = {}): readonly ReviewThread[] {
    const author = this.getLocalAuthor(wsName);
    return this.state(wsName).threads
      .filter((thread) => filter.status === undefined || filter.status === 'all' || thread.status === filter.status)
      .filter((thread) => !filter.file || thread.anchor?.filePath === filter.file)
      .filter((thread) => !filter.mine || thread.author.id === author.id)
      .filter((thread) => !filter.outdated || thread.status === 'outdated')
      .sort((a, b) => b.updatedAt - a.updatedAt);
  }

  getChanges(wsName: string, filter: ReviewChangeFilter = {}): readonly ReviewChange[] {
    return this.state(wsName).threads
      .filter((thread) => thread.kind === 'change' && thread.change)
      .map((thread) => thread.change!)
      .filter((change) => filter.status === undefined || filter.status === 'all' || change.status === filter.status)
      .filter((change) => !filter.file || change.filePath === filter.file)
      .filter((change) => !filter.authorId || change.author.id === filter.authorId)
      .sort((a, b) => a.anchor.from - b.anchor.from || a.createdAt - b.createdAt);
  }

  getActivity(wsName: string, filter: ReviewActivityFilter = {}): readonly ReviewActivity[] {
    return this.state(wsName).activity
      .filter((item) => !filter.file || item.filePath === filter.file)
      .filter((item) => !filter.authorId || item.author.id === filter.authorId)
      .filter((item) => !filter.kinds?.length || filter.kinds.includes(item.kind))
      .filter((item) => filter.since === undefined || item.createdAt >= filter.since)
      .filter((item) => filter.until === undefined || item.createdAt <= filter.until)
      .sort((a, b) => b.createdAt - a.createdAt);
  }

  getActivityAuthors(wsName: string): readonly ReviewAuthor[] {
    const authors = new Map<string, ReviewAuthor>();
    for (const item of this.state(wsName).activity) authors.set(item.author.id, item.author);
    return [...authors.values()].sort((a, b) => a.displayName.localeCompare(b.displayName));
  }

  getThread(wsName: string, id: string): ReviewThread | undefined {
    return this.state(wsName).threads.find((thread) => thread.id === id);
  }

  getProjectedSource(wsName: string, filePath: string, canonical: string): string {
    if (this.dependencies.projectCollaboration.isActive(wsName)) return canonical;
    return buildReviewProjection(canonical, this.getChanges(wsName, { status: 'pending', file: filePath })).text;
  }

  projectedRangeToCanonical(wsName: string, filePath: string, canonical: string, from: number, to: number): { from: number; to: number; changeId?: string } {
    if (this.dependencies.projectCollaboration.isActive(wsName)) {
      const safeFrom = Math.max(0, Math.min(from, canonical.length));
      const safeTo = Math.max(safeFrom, Math.min(to, canonical.length));
      const overlapping = this.getChanges(wsName, { status: 'pending', file: filePath }).filter((change) =>
        safeFrom === safeTo
          ? safeFrom >= change.anchor.from && safeFrom <= change.anchor.to
          : safeFrom < change.anchor.to && safeTo > change.anchor.from,
      );
      return { from: safeFrom, to: safeTo, ...(overlapping.length === 1 ? { changeId: overlapping[0]!.id } : {}) };
    }
    const changes = this.getChanges(wsName, { status: 'pending', file: filePath });
    const projection = buildReviewProjection(canonical, changes);
    const safeFrom = Math.max(0, Math.min(from, projection.text.length));
    const safeTo = Math.max(safeFrom, Math.min(to, projection.text.length));
    const overlapping = projection.segments.filter((segment) =>
      segment.kind === 'change'
      && (safeFrom === safeTo
        ? safeFrom >= segment.projectedFrom && safeFrom <= segment.projectedTo
        : safeFrom < segment.projectedTo && safeTo > segment.projectedFrom),
    );
    return {
      from: sourceBoundaryForProjected(projection, safeFrom, 'left'),
      to: sourceBoundaryForProjected(projection, safeTo, 'right'),
      ...(overlapping.length === 1 && overlapping[0]?.changeId ? { changeId: overlapping[0].changeId } : {}),
    };
  }

  getEditorDecorations(wsName: string, filePath: string, canonical: string): readonly ReviewEditorDecoration[] {
    if (this.dependencies.projectCollaboration.isActive(wsName)) {
      const result: ReviewEditorDecoration[] = [];
      for (const thread of this.getThreads(wsName, { status: 'all', file: filePath })) {
        if (!thread.anchor) continue;
        if (thread.kind === 'change') {
          if (!thread.change || (thread.change.status !== 'pending' && thread.change.status !== 'conflicted')) continue;
          result.push({
            id: thread.id,
            from: Math.max(0, Math.min(thread.anchor.from, canonical.length)),
            to: Math.max(0, Math.min(thread.anchor.to, canonical.length)),
            status: thread.status,
            kind: 'change',
            changeType: thread.change.type,
            originalText: thread.change.originalText,
            proposedText: thread.change.proposedText,
            authorName: thread.author.displayName,
            ...(thread.change.metadata?.automatic || thread.author.displayName.includes('Automatic') ? { automatic: true } : {}),
            ...(thread.author.color ? { authorColor: thread.author.color } : {}),
            ...(thread.change.status === 'conflicted' ? { conflicted: true } : {}),
          });
          continue;
        }
        result.push({
          id: thread.id,
          from: Math.max(0, Math.min(thread.anchor.from, canonical.length)),
          to: Math.max(0, Math.min(thread.anchor.to, canonical.length)),
          status: thread.status,
          kind: 'comment',
          authorName: thread.author.displayName,
          ...(thread.author.color ? { authorColor: thread.author.color } : {}),
        });
      }
      return result;
    }

    const changes = this.getChanges(wsName, { status: 'pending', file: filePath });
    const projection = buildReviewProjection(canonical, changes);
    const result: ReviewEditorDecoration[] = [];
    for (const thread of this.getThreads(wsName, { status: 'all', file: filePath })) {
      if (!thread.anchor) continue;
      if (thread.kind === 'change' && thread.change?.status === 'pending') {
        const segment = projection.segments.find((candidate) => candidate.changeId === thread.id);
        if (!segment) continue;
        result.push({ id: thread.id, from: segment.projectedFrom, to: segment.projectedTo, status: thread.status, kind: 'change', changeType: thread.change.type, originalText: thread.change.originalText, proposedText: thread.change.proposedText, authorName: thread.author.displayName, ...(thread.change.metadata?.automatic || thread.author.displayName.includes('Automatic') ? { automatic: true } : {}), ...(thread.author.color ? { authorColor: thread.author.color } : {}) });
        continue;
      }
      if (thread.kind === 'change') continue;
      const from = projectedBoundaryForSource(projection, thread.anchor.from, 'left');
      const to = projectedBoundaryForSource(projection, thread.anchor.to, 'right');
      result.push({ id: thread.id, from, to, status: thread.status, kind: 'comment', authorName: thread.author.displayName, ...(thread.author.color ? { authorColor: thread.author.color } : {}) });
    }
    return result;
  }

  /** Apply one editor splice to the transient local projection or the live collaborative Y.Text. */
  applyProjectedEdit(
    wsName: string,
    filePath: string,
    canonical: string,
    edit: SourceTextEdit,
    options: { semanticKind?: string; author?: ReviewAuthor; source?: SharedReviewChangeSource } = {},
  ): void {
    const state = this.state(wsName);
    if (!state.loaded || !state.projectId) throw new Error('Review state is not ready');
    if (this.dependencies.projectCollaboration.isSourcePathCollaborative(wsName, filePath)) {
      const source = options.source ?? (options.author?.kind === 'ai' ? 'ai' : 'toolbar');
      const applied = this.dependencies.projectCollaboration.applyTextEdits(wsName, filePath, [edit], { reviewSource: source, ...(options.semanticKind ? { semanticKind: options.semanticKind } : {}) });
      if (!applied) throw new Error(`Unable to apply collaborative review edit to ${filePath}`);
      return;
    }
    const changes = this.getChanges(wsName, { status: 'pending', file: filePath });
    const projection = buildReviewProjection(canonical, changes);
    const safeEdit = {
      from: Math.max(0, Math.min(edit.from, projection.text.length)),
      to: Math.max(0, Math.min(edit.to, projection.text.length)),
      text: edit.text,
    };
    if (safeEdit.to < safeEdit.from) return;

    const fromSegment = segmentAtProjected(projection, safeEdit.from);
    const toSegment = segmentAtProjected(projection, safeEdit.to);
    if (fromSegment?.changeId && fromSegment.changeId === toSegment?.changeId) {
      const thread = state.threads.find((candidate) => candidate.id === fromSegment.changeId && candidate.change?.status === 'pending');
      if (thread?.change) {
        const localFrom = Math.max(0, safeEdit.from - fromSegment.projectedFrom);
        const localTo = Math.max(localFrom, safeEdit.to - fromSegment.projectedFrom);
        const proposedText = applySourceTextEdit(thread.change.proposedText, { from: localFrom, to: localTo, text: safeEdit.text });
        if (proposedText === thread.change.originalText) {
          state.threads = state.threads.filter((candidate) => candidate.id !== thread.id);
        } else {
          const now = Date.now();
          const nextType = changeType(thread.change.originalText, proposedText);
          const nextChange: ReviewChange = { ...thread.change, type: nextType, proposedText, semanticOperation: inferSemanticOperation(nextType, thread.change.anchor.semanticKind, thread.change.originalText, proposedText), updatedAt: now };
          const nextThread: ReviewThread = { ...thread, body: changeBody(nextChange), updatedAt: now, change: nextChange };
          state.threads = state.threads.map((candidate) => candidate.id === thread.id ? nextThread : candidate);
        }
        void this.persistThreads(wsName);
        this.bump();
        return;
      }
    }

    const overlapping = changes.filter((change) => {
      const segment = projection.segments.find((candidate) => candidate.changeId === change.id);
      if (!segment) return false;
      if (safeEdit.from === safeEdit.to) return safeEdit.from >= segment.projectedFrom && safeEdit.from <= segment.projectedTo;
      return safeEdit.from < segment.projectedTo && safeEdit.to > segment.projectedFrom;
    });
    let sourceFrom = sourceBoundaryForProjected(projection, safeEdit.from, 'left');
    let sourceTo = sourceBoundaryForProjected(projection, safeEdit.to, 'right');
    for (const change of overlapping) {
      sourceFrom = Math.min(sourceFrom, change.anchor.from);
      sourceTo = Math.max(sourceTo, change.anchor.to);
    }
    const projectedFrom = projectedBoundaryForSource(projection, sourceFrom, 'left');
    const projectedTo = projectedBoundaryForSource(projection, sourceTo, 'right');
    const rangeText = projection.text.slice(projectedFrom, projectedTo);
    const proposedText = applySourceTextEdit(rangeText, {
      from: Math.max(0, safeEdit.from - projectedFrom),
      to: Math.max(0, safeEdit.to - projectedFrom),
      text: safeEdit.text,
    });
    const originalText = canonical.slice(sourceFrom, sourceTo);
    const removeIds = new Set(overlapping.map((change) => change.id));
    if (proposedText === originalText) {
      if (removeIds.size) {
        state.threads = state.threads.filter((thread) => !removeIds.has(thread.id));
        void this.persistThreads(wsName);
        this.bump();
      }
      return;
    }
    const now = Date.now();
    const author = options.author ?? this.getLocalAuthor(wsName);
    const id = overlapping[0]?.id ?? randomId('change');
    const isNewChange = !state.threads.some((candidate) => candidate.id === id);
    const semanticBlockKind = this.dependencies.projectSemantic
      .getSnapshot(wsName)
      ?.files.get(projectFilePath(filePath))
      ?.visualBlocks.find((block) => sourceFrom >= block.range.from && sourceFrom <= block.range.to)?.kind;
    const semanticKind = options.semanticKind ?? overlapping[0]?.anchor.semanticKind ?? semanticBlockKind ?? inferSemanticKind(canonical, sourceFrom);
    const anchor = captureSourceAnchor({
      filePath,
      source: canonical,
      from: sourceFrom,
      to: sourceTo,
      ...(this.dependencies.latexSource.getSnapshot(WsPath.fromParts(wsName, filePath).wsPath)?.revision !== undefined
        ? { sourceRevision: this.dependencies.latexSource.getSnapshot(WsPath.fromParts(wsName, filePath).wsPath)!.revision }
        : {}),
      ...(semanticKind ? { semanticKind } : {}),
    });
    const change: ReviewChange = {
      id,
      projectId: state.projectId,
      filePath,
      type: changeType(originalText, proposedText),
      presentation: changePresentation(canonical, sourceFrom, originalText, proposedText, semanticKind),
      ...(inferSemanticOperation(changeType(originalText, proposedText), semanticKind, originalText, proposedText) ? { semanticOperation: inferSemanticOperation(changeType(originalText, proposedText), semanticKind, originalText, proposedText)! } : {}),
      anchor,
      originalText,
      proposedText,
      author,
      status: 'pending',
      createdAt: overlapping[0]?.createdAt ?? now,
      updatedAt: now,
    };
    const thread: ReviewThread = {
      id,
      projectId: state.projectId,
      kind: 'change',
      scope: 'anchor',
      anchor,
      author,
      body: changeBody(change),
      status: 'open',
      createdAt: change.createdAt,
      updatedAt: now,
      replies: state.threads.find((candidate) => candidate.id === id)?.replies ?? [],
      change,
    };
    state.threads = [...state.threads.filter((candidate) => !removeIds.has(candidate.id) && candidate.id !== id), thread];
    void this.persistThreads(wsName);
    if (isNewChange) {
      void this.recordActivity(wsName, {
        kind: 'change-proposed',
        author,
        summary: `${author.kind === 'ai' ? 'AI suggested' : 'Proposed'} ${change.type} in ${filePath}`,
        threadId: id,
        filePath,
        changeType: change.type,
        source: author.kind === 'ai' ? 'ai' : 'user',
      });
    }
    this.bump();
  }

  /**
   * Create one source-grounded Review suggestion without mutating canonical
   * source. AI, Paper Review and deterministic fixers all use this bridge so
   * Source/Visual/collaboration render the same pending change.
   */
  async proposeSourceChange(wsName: string, input: ProposeReviewChangeInput): Promise<ReviewThread> {
    await this.refresh(wsName);
    const state = this.state(wsName);
    const filePath = projectFilePath(input.filePath);
    const source = await this.readReviewSource(wsName, filePath);
    const canonical = this.dependencies.projectCollaboration.getCanonicalSourceText(wsName, filePath) ?? source.text;
    let from = Math.max(0, Math.min(Math.trunc(input.from), canonical.length));
    let to = Math.max(from, Math.min(Math.trunc(input.to), canonical.length));
    if (input.sourceRevision !== undefined && source.sourceRevision !== undefined && input.sourceRevision !== source.sourceRevision) {
      const captured = input.sourceAnchor;
      if (!captured) throw new Error('AI suggestion is outdated because the source changed and no stable source anchor is available.');
      const resolved = resolveSourceAnchor(captured, { source: canonical, sourceRevision: source.sourceRevision });
      if (resolved.status !== 'resolved') throw new Error('AI suggestion is outdated because the source changed.');
      from = resolved.value.from;
      to = resolved.value.to;
    }
    const actualOriginal = canonical.slice(from, to);
    if (actualOriginal !== input.originalText) throw new Error('AI suggestion is outdated because the selected source no longer matches.');
    if (actualOriginal === input.proposedText) throw new Error('AI suggestion does not change the source.');
    const overlap = this.getChanges(wsName, { status: 'pending', file: filePath }).find((change) =>
      from === to
        ? from >= change.anchor.from && from <= change.anchor.to
        : from < change.anchor.to && to > change.anchor.from,
    );
    if (overlap) throw new Error('This source range already has an unresolved review suggestion.');

    const author = input.author ?? { id: 'interactive-ai-suggest', displayName: 'Learnitive AI', kind: 'ai' as const };
    const semanticKind = input.semanticKind ?? inferSemanticKind(canonical, from);
    const operationId = input.metadata?.operationId ?? randomId('ai-operation');

    if (this.dependencies.projectCollaboration.isSourcePathCollaborative(wsName, filePath)) {
      if (author.kind === 'ai') {
        const sharedMetadata: SharedReviewChangeMetadata = {
          ...(input.metadata?.automatic !== undefined ? { automatic: input.metadata.automatic } : {}),
          ...(input.metadata?.category ? { category: input.metadata.category } : {}),
          ...(input.metadata?.reason ? { reason: input.metadata.reason } : {}),
          ...(input.metadata?.confidence !== undefined ? { confidence: input.metadata.confidence } : {}),
          ...(input.metadata?.provider ? { provider: input.metadata.provider } : {}),
          ...(input.metadata?.model ? { model: input.metadata.model } : {}),
          ...(input.metadata?.reviewedRevision !== undefined ? { reviewedRevision: input.metadata.reviewedRevision } : {}),
          ...(input.metadata?.findingId ? { findingId: input.metadata.findingId } : {}),
        };
        const created = this.dependencies.projectCollaboration.proposeDeferredSharedReviewChange(wsName, {
          filePath,
          from,
          to,
          originalText: actualOriginal,
          proposedText: input.proposedText,
          author: {
            id: author.id,
            displayName: author.displayName,
            ...(author.color ? { color: author.color } : {}),
            ...(author.colorLight ? { colorLight: author.colorLight } : {}),
          },
          source: 'ai',
          ...(semanticKind ? { semanticKind } : {}),
          operationId,
          metadata: sharedMetadata,
        });
        if (!created) throw new Error(`Unable to create deferred collaborative AI review suggestion in ${filePath}`);
        this.syncSharedReview(wsName);
        const collaborative = this.getThread(wsName, created.id);
        if (collaborative) return collaborative;
        throw new Error(`Deferred collaborative AI review suggestion was created but could not be loaded from shared Review state (${created.id})`);
      }
      const applied = this.dependencies.projectCollaboration.applyTextEdits(
        wsName,
        filePath,
        [{ from, to, text: input.proposedText }],
        { reviewSource: 'toolbar', ...(semanticKind ? { semanticKind } : {}), operationId },
      );
      if (!applied) throw new Error(`Unable to create collaborative review suggestion in ${filePath}`);
      this.syncSharedReview(wsName);
      const collaborative = this.getChanges(wsName, { status: 'pending', file: filePath })
        .map((change) => this.getThread(wsName, change.id))
        .find((thread) => thread?.change?.metadata?.operationId === operationId);
      if (collaborative) return collaborative;
      // Yjs observers normally make the shared thread available synchronously,
      // but return a source-grounded transient representation if an adapter
      // dispatches the observer on the next microtask. The shared Review state
      // remains authoritative and will replace this object immediately.
      const anchor = captureSourceAnchor({ filePath, source: canonical, from, to, sourceRevision: source.sourceRevision, ...(semanticKind ? { semanticKind } : {}) });
      const now = Date.now();
      const type = changeType(actualOriginal, input.proposedText);
      const change: ReviewChange = {
        id: operationId,
        projectId: state.projectId!,
        filePath,
        type,
        presentation: changePresentation(canonical, from, actualOriginal, input.proposedText, semanticKind),
        anchor,
        originalText: actualOriginal,
        proposedText: input.proposedText,
        author,
        status: 'pending',
        metadata: { ...(input.metadata ?? {}), source: author.kind === 'system' ? 'system' : 'user', operationId },
        createdAt: now,
        updatedAt: now,
      };
      return { id: operationId, projectId: state.projectId!, kind: 'change', scope: 'anchor', anchor, author, body: changeBody(change), status: 'open', createdAt: now, updatedAt: now, replies: [], change };
    }

    const now = Date.now();
    const id = randomId('change');
    const type = changeType(actualOriginal, input.proposedText);
    const anchor = captureSourceAnchor({
      filePath,
      source: canonical,
      from,
      to,
      sourceRevision: source.sourceRevision,
      ...(semanticKind ? { semanticKind } : {}),
    });
    const semanticOperation = inferSemanticOperation(type, semanticKind, actualOriginal, input.proposedText);
    const metadata: ReviewChangeMetadata = {
      ...(input.metadata ?? {}),
      source: author.kind === 'ai' ? 'ai' : author.kind === 'system' ? 'system' : 'user',
      operationId,
    };
    const change: ReviewChange = {
      id,
      projectId: state.projectId!,
      filePath,
      type,
      presentation: changePresentation(canonical, from, actualOriginal, input.proposedText, semanticKind),
      ...(semanticOperation ? { semanticOperation } : {}),
      anchor,
      originalText: actualOriginal,
      proposedText: input.proposedText,
      author,
      status: 'pending',
      metadata,
      createdAt: now,
      updatedAt: now,
    };
    const reason = metadata.reason?.trim();
    const thread: ReviewThread = {
      id,
      projectId: state.projectId!,
      kind: 'change',
      scope: 'anchor',
      anchor,
      author,
      body: reason ? `${changeBody(change)}\n\n${reason}` : changeBody(change),
      status: 'open',
      createdAt: now,
      updatedAt: now,
      replies: [],
      change,
    };
    state.threads = [...state.threads, thread];
    await this.persistThreads(wsName);
    await this.recordActivity(wsName, {
      kind: 'change-proposed',
      author,
      summary: `${author.kind === 'ai' ? 'AI suggested' : 'Proposed'} ${type} in ${filePath}`,
      threadId: id,
      filePath,
      changeType: type,
      source: author.kind === 'ai' ? 'ai' : 'user',
      metadata: {
        ...(metadata.category ? { category: metadata.category } : {}),
        ...(metadata.provider ? { provider: metadata.provider } : {}),
        ...(metadata.model ? { model: metadata.model } : {}),
        operationId,
      },
    });
    this.bump();
    return thread;
  }

  applyProjectedText(wsName: string, filePath: string, canonical: string, nextProjected: string, options: { semanticKind?: string; author?: ReviewAuthor; source?: SharedReviewChangeSource } = {}): void {
    const projection = this.getProjectedSource(wsName, filePath, canonical);
    const edit = computeMinimalSourceTextEdit(projection, nextProjected);
    if (!edit) return;
    this.applyProjectedEdit(wsName, filePath, canonical, edit, options);
  }

  async createComment(wsName: string, input: CreateReviewCommentInput): Promise<ReviewThread> {
    const body = input.body.trim();
    if (!body) throw new Error('Comment cannot be empty');
    await this.refresh(wsName);
    const state = this.state(wsName);
    if (state.backend.kind === 'collaboration') {
      const shared = this.dependencies.projectCollaboration.createSharedReviewComment(wsName, {
        body,
        scope: input.scope ?? (input.anchor ? 'anchor' : 'project'),
        ...(input.anchor ? { filePath: input.anchor.filePath, from: input.anchor.from, to: input.anchor.to, quotedText: input.anchor.selectedText } : {}),
      });
      if (!shared) throw new Error('Unable to create collaborative comment');
      this.syncSharedReview(wsName);
      const mapped = this.getThread(wsName, shared.id);
      if (!mapped) throw new Error('Collaborative comment was not synchronized');
      this.bump();
      return mapped;
    }
    const now = Date.now();
    const thread: ReviewThread = {
      id: randomId('review'),
      projectId: state.projectId!,
      kind: 'comment',
      scope: input.scope ?? (input.anchor ? 'anchor' : 'project'),
      ...(input.anchor ? { anchor: structuredClone(input.anchor) } : {}),
      author: input.author ?? this.getLocalAuthor(wsName),
      body,
      status: 'open',
      createdAt: now,
      updatedAt: now,
      replies: [],
    };
    state.threads = [...state.threads, thread];
    await this.commit(wsName);
    await this.recordActivity(wsName, {
      kind: 'comment-created',
      author: thread.author,
      summary: `${thread.scope === 'project' ? 'Project' : thread.scope === 'document' ? 'Document' : 'Source'} comment created`,
      threadId: thread.id,
      ...(thread.anchor?.filePath ? { filePath: thread.anchor.filePath } : {}),
      source: thread.author.kind === 'ai' ? 'ai' : 'user',
    });
    return thread;
  }

  async reply(wsName: string, id: string, body: string): Promise<void> {
    const text = body.trim();
    if (!text) return;
    await this.refresh(wsName);
    if (this.state(wsName).backend.kind === 'collaboration') {
      this.dependencies.projectCollaboration.replySharedReviewComment(wsName, id, text);
      this.syncSharedReview(wsName);
      this.bump();
      return;
    }
    const author = this.getLocalAuthor(wsName);
    const existing = this.getThread(wsName, id);
    if (!existing) return;
    let filePath: string | undefined;
    await this.updateThread(wsName, id, (thread) => {
      filePath = thread.anchor?.filePath;
      const now = Date.now();
      return { ...thread, updatedAt: now, replies: [...thread.replies, { id: randomId('reply'), author, body: text, createdAt: now, updatedAt: now }] };
    });
    await this.recordActivity(wsName, { kind: 'comment-replied', author, summary: 'Replied to review thread', threadId: id, ...(filePath ? { filePath } : {}), source: 'user' });
  }

  async editOwnComment(wsName: string, id: string, body: string): Promise<void> {
    const text = body.trim();
    if (!text) throw new Error('Comment cannot be empty');
    await this.refresh(wsName);
    const author = this.getLocalAuthor(wsName);
    const existing = this.getThread(wsName, id);
    if (!existing) return;
    if (existing.kind === 'change') throw new Error('Tracked changes cannot be edited as comments');
    if (existing.author.id !== author.id) throw new Error('Only your own comment can be edited');
    if (this.state(wsName).backend.kind === 'collaboration') {
      this.dependencies.projectCollaboration.editSharedReviewComment(wsName, id, text);
      this.syncSharedReview(wsName);
      this.bump();
      return;
    }
    let filePath: string | undefined;
    await this.updateThread(wsName, id, (thread) => {
      filePath = thread.anchor?.filePath;
      return { ...thread, body: text, updatedAt: Date.now() };
    });
    await this.recordActivity(wsName, { kind: 'comment-edited', author, summary: 'Comment edited', threadId: id, ...(filePath ? { filePath } : {}), source: 'user' });
  }

  async deleteOwnComment(wsName: string, id: string): Promise<void> {
    await this.refresh(wsName);
    const state = this.state(wsName);
    const thread = state.threads.find((candidate) => candidate.id === id);
    if (!thread) return;
    if (thread.kind === 'change') throw new Error('Tracked changes must be accepted or rejected');
    if (thread.author.id !== this.getLocalAuthor(wsName).id) throw new Error('Only your own comment can be deleted');
    if (state.backend.kind === 'collaboration') {
      this.dependencies.projectCollaboration.deleteSharedReviewComment(wsName, id);
      this.syncSharedReview(wsName);
      this.bump();
      return;
    }
    state.threads = state.threads.filter((candidate) => candidate.id !== id);
    this.bump();
    await this.persistThreads(wsName);
    await this.recordActivity(wsName, { kind: 'comment-deleted', author: thread.author, summary: 'Comment deleted', threadId: id, ...(thread.anchor?.filePath ? { filePath: thread.anchor.filePath } : {}), source: 'user' });
  }

  async resolve(wsName: string, id: string): Promise<void> {
    await this.refresh(wsName);
    const thread = this.getThread(wsName, id);
    if (!thread || thread.kind === 'change') return;
    if (this.state(wsName).backend.kind === 'collaboration') {
      this.dependencies.projectCollaboration.setSharedReviewCommentResolved(wsName, id, true);
      this.syncSharedReview(wsName);
      this.bump();
      return;
    }
    await this.updateThread(wsName, id, (current) => ({ ...current, status: 'resolved', updatedAt: Date.now() }));
    await this.recordActivity(wsName, { kind: 'comment-resolved', author: this.getLocalAuthor(wsName), summary: 'Comment resolved', threadId: id, ...(thread.anchor?.filePath ? { filePath: thread.anchor.filePath } : {}), source: 'user' });
  }

  async reopen(wsName: string, id: string): Promise<void> {
    await this.refresh(wsName);
    const thread = this.getThread(wsName, id);
    if (!thread || thread.kind === 'change') return;
    if (this.state(wsName).backend.kind === 'collaboration') {
      this.dependencies.projectCollaboration.setSharedReviewCommentResolved(wsName, id, false);
      this.syncSharedReview(wsName);
      this.bump();
      return;
    }
    await this.updateThread(wsName, id, (current) => ({ ...current, status: 'open', updatedAt: Date.now() }));
    await this.recordActivity(wsName, { kind: 'comment-reopened', author: this.getLocalAuthor(wsName), summary: 'Comment reopened', threadId: id, ...(thread.anchor?.filePath ? { filePath: thread.anchor.filePath } : {}), source: 'user' });
  }

  async acceptReviewChange(wsName: string, id: string, options: { skipCheckpoint?: boolean } = {}): Promise<void> {
    await this.refresh(wsName);
    const currentThread = this.getThread(wsName, id);
    if (!currentThread?.change || currentThread.change.status !== 'pending') return;
    if (this.state(wsName).backend.kind === 'collaboration') {
      this.dependencies.projectCollaboration.acceptSharedReviewChange(wsName, id);
      this.syncSharedReview(wsName);
      this.bump();
      return;
    }
    const recovered = await this.recoverThread(wsName, currentThread);
    if (!recovered.change || !recovered.anchor || recovered.status === 'outdated' || recovered.change.status === 'outdated') throw new Error('This tracked change is outdated and cannot be applied safely.');
    const source = await this.readReviewSource(wsName, recovered.change.filePath);
    if (source.text.slice(recovered.anchor.from, recovered.anchor.to) !== recovered.change.originalText) throw new Error('The source changed and this tracked change can no longer be applied safely.');
    if (!options.skipCheckpoint && recovered.change.author.kind === 'ai') {
      await this.captureCheckpoint(wsName, recovered.change.filePath, 'Before AI review acceptance');
    }
    const nextSource = applySourceTextEdit(source.text, { from: recovered.anchor.from, to: recovered.anchor.to, text: recovered.change.proposedText });
    await this.writeReviewSource(wsName, recovered.change.filePath, nextSource);
    const now = Date.now();
    await this.updateThread(wsName, id, (thread) => ({ ...thread, status: 'resolved', updatedAt: now, change: thread.change ? { ...thread.change, status: 'accepted', updatedAt: now } : undefined }));
    await this.recordActivity(wsName, { kind: 'change-accepted', author: this.getLocalAuthor(wsName), summary: `Accepted ${recovered.change.type} in ${recovered.change.filePath}`, threadId: id, filePath: recovered.change.filePath, changeType: recovered.change.type, source: recovered.change.author.kind === 'ai' ? 'ai' : 'user' });
    await this.recoverAnchors(wsName);
    this.bump();
  }

  async rejectReviewChange(wsName: string, id: string): Promise<void> {
    await this.refresh(wsName);
    if (this.state(wsName).backend.kind === 'collaboration') {
      this.dependencies.projectCollaboration.rejectSharedReviewChange(wsName, id);
      this.syncSharedReview(wsName);
      this.bump();
      return;
    }
    const now = Date.now();
    let rejected: ReviewChange | undefined;
    await this.updateThread(wsName, id, (thread) => {
      if (!thread.change || thread.change.status !== 'pending') return thread;
      rejected = thread.change;
      return { ...thread, status: 'resolved', updatedAt: now, change: { ...thread.change, status: 'rejected', updatedAt: now } };
    });
    if (rejected) await this.recordActivity(wsName, { kind: 'change-rejected', author: this.getLocalAuthor(wsName), summary: `Rejected ${rejected.type} in ${rejected.filePath}`, threadId: id, filePath: rejected.filePath, changeType: rejected.type, source: rejected.author.kind === 'ai' ? 'ai' : 'user' });
    this.bump();
  }

  async resolveConflictKeepCurrent(wsName: string, id: string): Promise<void> {
    await this.refresh(wsName);
    if (this.state(wsName).backend.kind !== 'collaboration') return;
    this.dependencies.projectCollaboration.keepCurrentSharedReviewConflict(wsName, id);
    this.syncSharedReview(wsName);
    this.bump();
  }

  async resolveConflictRestoreOriginal(wsName: string, id: string): Promise<void> {
    await this.refresh(wsName);
    if (this.state(wsName).backend.kind !== 'collaboration') return;
    this.dependencies.projectCollaboration.restoreOriginalSharedReviewConflict(wsName, id);
    this.syncSharedReview(wsName);
    this.bump();
  }

  async dismissConflict(wsName: string, id: string): Promise<void> {
    await this.refresh(wsName);
    if (this.state(wsName).backend.kind !== 'collaboration') return;
    this.dependencies.projectCollaboration.dismissSharedReviewConflict(wsName, id);
    this.syncSharedReview(wsName);
    this.bump();
  }

  async acceptChanges(wsName: string, ids: readonly string[]): Promise<void> {
    await this.refresh(wsName);
    const pending = ids.map((id) => this.getThread(wsName, id)?.change).filter((change): change is ReviewChange => Boolean(change?.status === 'pending'));
    if (pending.length > 1 && this.state(wsName).backend.kind !== 'collaboration') {
      for (const filePath of [...new Set(pending.map((change) => change.filePath))]) await this.captureCheckpoint(wsName, filePath, 'Before bulk review acceptance');
    }
    for (const id of ids) await this.acceptReviewChange(wsName, id, { skipCheckpoint: pending.length > 1 });
  }

  async rejectChanges(wsName: string, ids: readonly string[]): Promise<void> {
    for (const id of ids) await this.rejectReviewChange(wsName, id);
  }

  async recoverThread(wsName: string, thread: ReviewThread): Promise<ReviewThread> {
    if (!thread.anchor) return thread;
    if (thread.change && thread.change.status !== 'pending' && thread.change.status !== 'outdated') return thread;
    const wsPath = WsPath.fromParts(wsName, thread.anchor.filePath).wsPath;
    const snapshot = this.dependencies.latexSource.getSnapshot(wsPath);
    let source = snapshot?.text;
    if (source === undefined) {
      const provider = await this.dependencies.projectStorage.getProvider(wsName);
      const bytes = await provider.readFile(projectFilePath(thread.anchor.filePath));
      source = bytes ? new TextDecoder('utf-8', { fatal: false }).decode(bytes) : undefined;
    }
    if (source === undefined) return thread;
    const semantic = this.dependencies.projectSemantic.getSnapshot(wsName);
    const result = resolveSourceAnchor(thread.anchor, {
      source,
      ...(snapshot ? { sourceRevision: snapshot.revision } : {}),
      semanticRange: (anchor) => {
        if (!anchor.semanticId || !semantic) return undefined;
        const file = semantic.files.get(anchor.filePath as ProjectFilePath);
        const node = file?.nodes.find((candidate) => candidate.id === anchor.semanticId);
        return node?.range;
      },
    });
    if (result.status === 'outdated') {
      if (thread.change) return { ...thread, status: 'outdated', change: { ...thread.change, status: 'outdated' } };
      return thread.status === 'resolved' ? thread : { ...thread, status: 'outdated' };
    }
    const nextAnchor = { ...thread.anchor, from: result.value.from, to: result.value.to, ...(snapshot ? { sourceRevision: snapshot.revision } : {}) };
    if (thread.change) {
      const nextChange = { ...thread.change, anchor: nextAnchor, ...(thread.change.status === 'outdated' ? { status: 'pending' as const } : {}) };
      return { ...thread, anchor: nextAnchor, change: nextChange, ...(thread.status === 'outdated' ? { status: 'open' as const } : {}) };
    }
    return { ...thread, anchor: nextAnchor, ...(thread.status === 'outdated' ? { status: 'open' as const } : {}) };
  }

  private syncSharedReview(wsName: string): void {
    const state = this.state(wsName);
    if (state.backend.kind !== 'collaboration' || !state.projectId) return;
    const snapshot = this.dependencies.projectCollaboration.getSharedReviewSnapshot(wsName);
    if (!snapshot) return;
    const mapped = mapSharedReviewSnapshot(
      state.projectId,
      snapshot,
      (filePath) => this.dependencies.projectCollaboration.getCollaborativeText(wsName, filePath),
    );
    state.threads = mapped.threads;
    state.activity = mapped.activity;
  }

  private async recoverAnchors(wsName: string): Promise<void> {
    const state = this.state(wsName);
    if (!state.loaded || state.backend.kind === 'collaboration') return;
    const previous = state.threads;
    const recovered = await Promise.all(previous.map((thread) => this.recoverThread(wsName, thread)));
    const newlyOutdated = recovered.filter((thread, index) => thread.status === 'outdated' && previous[index]?.status !== 'outdated');
    if (recovered.some((thread, index) => JSON.stringify(thread) !== JSON.stringify(previous[index]))) {
      state.threads = recovered;
      if (state.projectId) await this.persistThreads(wsName);
      for (const thread of newlyOutdated) {
        await this.recordActivity(wsName, { kind: 'change-outdated', author: { id: 'interactive-system', displayName: 'Learnitive', kind: 'system' }, summary: thread.change ? 'Tracked change became outdated' : 'Review anchor became outdated', threadId: thread.id, ...(thread.anchor?.filePath ? { filePath: thread.anchor.filePath } : {}), ...(thread.change?.type ? { changeType: thread.change.type } : {}), source: 'system' });
      }
    }
  }


  private async updateThread(wsName: string, id: string, update: (thread: ReviewThread) => ReviewThread): Promise<void> {
    await this.refresh(wsName);
    const state = this.state(wsName);
    const index = state.threads.findIndex((thread) => thread.id === id);
    if (index < 0) return;
    const next = update(state.threads[index]!);
    state.threads = state.threads.map((thread, position) => position === index ? next : thread);
    await this.commit(wsName);
  }

  private async commit(wsName: string): Promise<void> {
    await this.persistThreads(wsName);
    this.bump();
  }

  async createManualSnapshot(wsName: string, filePath: string): Promise<void> {
    await this.refresh(wsName);
    await this.captureCheckpoint(wsName, filePath, 'Manual review snapshot');
  }

  private async captureCheckpoint(wsName: string, filePath: string, reason: string): Promise<void> {
    const wsPath = WsPath.fromParts(wsName, filePath).wsPath;
    const loaded = this.dependencies.latexSource.getSnapshot(wsPath);
    let content = loaded?.text;
    if (content === undefined) {
      const provider = await this.dependencies.projectStorage.getProvider(wsName);
      const bytes = await provider.readFile(projectFilePath(filePath));
      content = bytes ? new TextDecoder('utf-8', { fatal: false }).decode(bytes) : undefined;
    }
    if (content === undefined || content.trim() === '') return;
    await this.dependencies.noteSnapshot.captureCheckpoint(wsPath, content);
    await this.recordActivity(wsName, {
      kind: 'snapshot-created',
      author: this.getLocalAuthor(wsName),
      summary: reason,
      filePath,
      source: 'system',
      metadata: { reason },
    });
  }

  private async recordActivity(
    wsName: string,
    input: Omit<ReviewActivity, 'id' | 'projectId' | 'createdAt'> & { createdAt?: number },
  ): Promise<void> {
    const state = this.state(wsName);
    if (state.backend.kind === 'collaboration') return;
    if (!state.projectId) state.projectId = await this.ensureProjectId(wsName);
    if (!state.activityLoaded) {
      state.activity = await readReviewActivity(state.projectId);
      if (state.activity.length === 0 && state.threads.length > 0) {
        state.activity = bootstrapReviewActivity(state.projectId, state.threads);
        await writeReviewActivity(state.projectId, state.activity);
      }
      state.activityLoaded = true;
    }
    const activity: ReviewActivity = {
      ...input,
      id: randomId('review-activity'),
      projectId: state.projectId,
      createdAt: input.createdAt ?? Date.now(),
    };
    state.activity = [...state.activity, activity]
      .sort((a, b) => a.createdAt - b.createdAt)
      .slice(-REVIEW_ACTIVITY_MAX);
    await writeReviewActivity(state.projectId, state.activity);
    this.bump();
  }

  private async ensureProjectId(wsName: string): Promise<string> {
    const metadata = await this.dependencies.workspaceOps.getWorkspaceMetadata(wsName);
    const existing = metadata[REVIEW_METADATA_KEY];
    if (isReviewMetadata(existing)) return existing.id;
    const id = randomId('review-project');
    await this.dependencies.workspaceOps.updateWorkspaceMetadata(wsName, (current) => ({ ...current, [REVIEW_METADATA_KEY]: { version: 1, id } }));
    return id;
  }


  private async consumeLegacySourceReviewLedger(wsName: string, projectId: string): Promise<ReviewThread[]> {
    try {
      const config = await this.dependencies.projectStorage.getProjectConfig(wsName);
      const wsPath = WsPath.fromParts(wsName, config.rootFile).wsPath;
      let source = this.dependencies.latexSource.getSnapshot(wsPath)?.text;
      if (source === undefined) source = await this.dependencies.latexSource.load(wsPath);
      if (!source.includes(REVIEW_LEDGER_BEGIN)) return [];
      const legacy = parseLegacyReviewLedger(source, projectId);
      const cleaned = stripLegacyReviewLedger(source);
      if (cleaned !== source) {
        if (this.dependencies.projectCollaboration.isSourcePathCollaborative(wsName, config.rootFile)) {
          this.dependencies.projectCollaboration.replaceCollaborativeText(wsName, config.rootFile, cleaned, { reviewSource: 'system', trackReview: false });
        } else {
          this.dependencies.latexSource.update(wsPath, cleaned, 'system', { history: 'ignore' });
        }
      }
      return legacy;
    } catch {
      return [];
    }
  }

  private async persistThreads(wsName: string): Promise<void> {
    const state = this.state(wsName);
    if (!state.projectId || state.backend.kind === 'collaboration') return;
    await writeReviewThreads(state.projectId, state.threads);
  }

  private state(wsName: string): WorkspaceReviewState {
    let state = this.workspaces.get(wsName);
    if (!state) {
      state = { threads: [], loaded: false, trackChanges: false, trackPreferenceLoaded: false, activity: [], activityLoaded: false, legacyLedgerChecked: false, backend: LOCAL_REVIEW_BACKEND };
      this.workspaces.set(wsName, state);
    }
    return state;
  }

  private bump(): void {
    this.store.set(this.$version, this.store.get(this.$version) + 1);
  }
}
