import * as Y from 'yjs';
import type { CollaborationUser } from './index';
import {
  COLLABORATION_REVIEW_SCHEMA_VERSION,
  COLLABORATION_REVIEW_TRANSACTION_META_KEY,
  type CollaborationReviewMutationMeta,
} from './collaboration-capabilities';

export { COLLABORATION_REVIEW_SCHEMA_VERSION } from './collaboration-capabilities';
export const COLLABORATION_REVIEW_MAINTENANCE_ORIGIN = 'interactive-collaboration-review-maintenance';
const REVIEW_EVENT_MAX = 5000;
const COMMENT_BODY_MAX = 20_000;
const REPLY_BODY_MAX = 8_000;
const CHANGE_TEXT_MAX = 250_000;
const CHANGE_CONTEXT_MAX = 64;
const REPLY_MAX = 500;
const COMMENT_THREAD_MAX = 2_000;
const CHANGE_RECORD_MAX = 5_000;
const RESOLVED_COMMENT_RETAIN = 500;
const RESOLVED_CHANGE_RETAIN = 1_000;
const REVIEW_SNAPSHOT_BYTES_MAX = 8 * 1024 * 1024;
const COALESCE_MS = 1750;

export type SharedReviewAuthor = {
  id: string;
  displayName: string;
  color?: string;
  colorLight?: string;
};

export type SharedReviewReply = {
  id: string;
  author: SharedReviewAuthor;
  body: string;
  createdAt: number;
  updatedAt: number;
};

export type SharedReviewComment = {
  id: string;
  version: number;
  filePath?: string;
  scope: 'anchor' | 'document' | 'project';
  author: SharedReviewAuthor;
  body: string;
  quotedText?: string;
  createdAt: number;
  updatedAt: number;
  status: 'open' | 'resolved' | 'outdated';
  from?: number;
  to?: number;
  anchorRemoved?: boolean;
  replies: SharedReviewReply[];
};

export type SharedReviewChangeType = 'insert' | 'delete' | 'replace';
export type SharedReviewChangeState = 'pending' | 'accepted' | 'rejected' | 'conflicted';
export type SharedReviewChangeApplicationMode = 'applied' | 'deferred';

export type SharedReviewChangeMetadata = {
  automatic?: boolean;
  category?: string;
  reason?: string;
  confidence?: number;
  provider?: string;
  model?: string;
  reviewedRevision?: string | number;
  findingId?: string;
};
export type SharedReviewChangeSource = 'typing' | 'toolbar' | 'ai' | 'citation' | 'paper-memory' | 'tikz' | 'terminal' | 'research' | 'system';

export type SharedReviewChange = {
  id: string;
  version: number;
  filePath: string;
  type: SharedReviewChangeType;
  author: SharedReviewAuthor;
  originalText: string;
  proposedText: string;
  createdAt: number;
  updatedAt: number;
  state: SharedReviewChangeState;
  source: SharedReviewChangeSource;
  applicationMode: SharedReviewChangeApplicationMode;
  semanticKind?: string;
  metadata?: SharedReviewChangeMetadata;
  operationId?: string;
  contextBefore?: string;
  contextAfter?: string;
  from?: number;
  to?: number;
  anchorRemoved?: boolean;
  replies: SharedReviewReply[];
};

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

export type SharedReviewEvent = {
  id: string;
  version: number;
  kind: SharedReviewEventKind;
  author: SharedReviewAuthor;
  createdAt: number;
  summary: string;
  threadId?: string;
  filePath?: string;
  changeType?: SharedReviewChangeType;
  source?: SharedReviewChangeSource;
  semanticKind?: string;
  operationId?: string;
};

export type SharedReviewSnapshot = {
  comments: SharedReviewComment[];
  changes: SharedReviewChange[];
  events: SharedReviewEvent[];
};

type YTextDeltaPart = { retain?: number; delete?: number; insert?: unknown };

type TrackedDeltaEdit = {
  oldFrom: number;
  oldTo: number;
  newFrom: number;
  newTo: number;
  originalText: string;
  proposedText: string;
};

type CoalescedChange = {
  id: string;
  authorId: string;
  type: SharedReviewChangeType;
  lastAt: number;
  lastOldFrom: number;
};

export type SharedReviewUndoSnapshot = { records: Array<Record<string, unknown>> };

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

function boundedString(value: unknown, max: number): string | undefined {
  if (typeof value !== 'string') return undefined;
  if (value.length > max) return undefined;
  return value;
}

function boundedOptionalString(value: unknown, max: number): string | undefined {
  if (value === undefined || value === null || value === '') return undefined;
  return boundedString(value, max);
}

function reviewChangeMetadata(value: unknown): SharedReviewChangeMetadata | undefined {
  if (value === undefined) return undefined;
  if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
  const candidate = value as Partial<SharedReviewChangeMetadata>;
  if (candidate.automatic !== undefined && typeof candidate.automatic !== 'boolean') return undefined;
  if (candidate.confidence !== undefined && (typeof candidate.confidence !== 'number' || !Number.isFinite(candidate.confidence) || candidate.confidence < 0 || candidate.confidence > 1)) return undefined;
  if (candidate.reviewedRevision !== undefined && typeof candidate.reviewedRevision !== 'string' && typeof candidate.reviewedRevision !== 'number') return undefined;
  const category = boundedOptionalString(candidate.category, 120);
  const reason = boundedOptionalString(candidate.reason, 2_000);
  const provider = boundedOptionalString(candidate.provider, 120);
  const model = boundedOptionalString(candidate.model, 200);
  const findingId = boundedOptionalString(candidate.findingId, 160);
  if ((candidate.category && category === undefined) || (candidate.reason && reason === undefined) || (candidate.provider && provider === undefined) || (candidate.model && model === undefined) || (candidate.findingId && findingId === undefined)) return undefined;
  return {
    ...(candidate.automatic !== undefined ? { automatic: candidate.automatic } : {}),
    ...(category ? { category } : {}),
    ...(reason ? { reason } : {}),
    ...(candidate.confidence !== undefined ? { confidence: candidate.confidence } : {}),
    ...(provider ? { provider } : {}),
    ...(model ? { model } : {}),
    ...(candidate.reviewedRevision !== undefined ? { reviewedRevision: candidate.reviewedRevision } : {}),
    ...(findingId ? { findingId } : {}),
  };
}

function validId(value: unknown): value is string {
  return typeof value === 'string' && value.length > 0 && value.length <= 120 && /^[A-Za-z0-9:_-]+$/u.test(value);
}

function validTimestamp(value: unknown): value is number {
  return typeof value === 'number' && Number.isFinite(value) && value > 0 && value < Date.now() + 24 * 60 * 60 * 1000;
}

function validFilePath(value: unknown): value is string {
  return typeof value === 'string'
    && value.length > 0
    && value.length <= 500
    && !value.startsWith('/')
    && !value.includes('\\')
    && !value.split('/').some((part) => part === '..' || part === '.');
}

function authorFromUser(user: CollaborationUser | undefined): SharedReviewAuthor {
  return user
    ? { id: user.id, displayName: user.name, color: user.color, colorLight: user.colorLight }
    : { id: 'unknown-collaborator', displayName: 'Collaborator' };
}

function isAuthor(value: unknown): value is SharedReviewAuthor {
  if (!value || typeof value !== 'object') return false;
  const candidate = value as Partial<SharedReviewAuthor>;
  return validId(candidate.id)
    && typeof candidate.displayName === 'string'
    && candidate.displayName.length > 0
    && candidate.displayName.length <= 80
    && (candidate.color === undefined || /^#[0-9a-f]{6}$/iu.test(candidate.color))
    && (candidate.colorLight === undefined || /^#[0-9a-f]{8}$/iu.test(candidate.colorLight));
}

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

function cloneRecord(map: Y.Map<unknown>): Record<string, unknown> {
  const result: Record<string, unknown> = {};
  for (const [key, value] of map.entries()) {
    if (value instanceof Y.Array) {
      if (key === 'replies') result[key] = value.toArray().map((item) => structuredClone(item));
      continue;
    }
    if (value instanceof Y.Map || value instanceof Y.Text) continue;
    result[key] = value instanceof Uint8Array ? new Uint8Array(value) : structuredClone(value);
  }
  return result;
}

function applyRecord(map: Y.Map<unknown>, record: Record<string, unknown>): void {
  for (const [key, value] of Object.entries(record)) {
    if (key === 'replies' && Array.isArray(value)) {
      const replies = new Y.Array<SharedReviewReply>();
      replies.push(value.filter((item): item is SharedReviewReply => Boolean(item && typeof item === 'object')).map((item) => structuredClone(item)));
      map.set(key, replies);
      continue;
    }
    map.set(key, value);
  }
  if (!(map.get('replies') instanceof Y.Array)) map.set('replies', new Y.Array<SharedReviewReply>());
}

function deltaEdits(before: string, delta: readonly YTextDeltaPart[]): TrackedDeltaEdit[] {
  const edits: TrackedDeltaEdit[] = [];
  let oldPos = 0;
  let newPos = 0;
  let current: TrackedDeltaEdit | undefined;
  const flush = () => {
    if (!current) return;
    if (current.originalText !== current.proposedText) edits.push(current);
    current = undefined;
  };
  for (const part of delta) {
    if (typeof part.retain === 'number' && part.retain > 0) {
      flush();
      oldPos += part.retain;
      newPos += part.retain;
      continue;
    }
    if (typeof part.delete === 'number' && part.delete > 0) {
      current ??= { oldFrom: oldPos, oldTo: oldPos, newFrom: newPos, newTo: newPos, originalText: '', proposedText: '' };
      current.originalText += before.slice(oldPos, oldPos + part.delete);
      oldPos += part.delete;
      current.oldTo = oldPos;
      continue;
    }
    if (typeof part.insert === 'string' && part.insert) {
      current ??= { oldFrom: oldPos, oldTo: oldPos, newFrom: newPos, newTo: newPos, originalText: '', proposedText: '' };
      current.proposedText += part.insert;
      newPos += part.insert.length;
      current.newTo = newPos;
      continue;
    }
    flush();
  }
  flush();
  return edits;
}

function encodeAnchor(text: Y.Text, from: number, to: number): { start: Uint8Array; end: Uint8Array } {
  const safeFrom = Math.max(0, Math.min(text.length, Math.trunc(from)));
  const safeTo = Math.max(safeFrom, Math.min(text.length, Math.trunc(to)));
  const point = safeTo === safeFrom;
  return {
    // Range starts follow the first selected character when text is inserted at
    // the boundary; point anchors stay attached to the character on their left.
    start: Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(text, safeFrom, point ? -1 : 1)),
    // Range ends stay attached to the last selected character so text inserted
    // immediately after the range is not accidentally absorbed by the review.
    end: Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(text, safeTo, -1)),
  };
}

function resolveEncodedAnchor(
  doc: Y.Doc,
  start: unknown,
  end: unknown,
  expectedText: Y.Text,
): { from: number; to: number } | undefined {
  if (!(start instanceof Uint8Array) || !(end instanceof Uint8Array)) return undefined;
  try {
    const a = Y.createAbsolutePositionFromRelativePosition(Y.decodeRelativePosition(start), doc);
    const b = Y.createAbsolutePositionFromRelativePosition(Y.decodeRelativePosition(end), doc);
    // A review record is only valid when both relative positions resolve into
    // the exact Y.Text declared by its filePath. This prevents a malformed peer
    // record from resolving offsets in one file and applying a reject/restore
    // mutation to another collaborative file.
    if (!a || !b || a.type !== expectedText || b.type !== expectedText || a.type !== b.type || !Number.isInteger(a.index) || !Number.isInteger(b.index)) return undefined;
    return { from: Math.min(a.index, b.index), to: Math.max(a.index, b.index) };
  } catch {
    return undefined;
  }
}

function sourceFromOrigin(origin: unknown): SharedReviewChangeSource {
  if (typeof origin === 'string' && origin.startsWith('interactive-collaboration-edit:')) {
    const source = origin.slice('interactive-collaboration-edit:'.length);
    if (['toolbar', 'ai', 'citation', 'paper-memory', 'tikz', 'terminal', 'research', 'system'].includes(source)) return source as SharedReviewChangeSource;
  }
  if (origin && typeof origin === 'object') {
    const source = (origin as { learnitiveReviewSource?: unknown }).learnitiveReviewSource;
    if (['typing', 'toolbar', 'ai', 'citation', 'paper-memory', 'tikz', 'terminal', 'research', 'system'].includes(String(source))) {
      return source as SharedReviewChangeSource;
    }
  }
  return 'typing';
}

function reviewMutationMeta(transaction: Y.Transaction): CollaborationReviewMutationMeta {
  const candidate = transaction.meta.get(COLLABORATION_REVIEW_TRANSACTION_META_KEY) as Partial<CollaborationReviewMutationMeta> | undefined;
  const source = candidate && ['typing', 'toolbar', 'ai', 'citation', 'paper-memory', 'tikz', 'terminal', 'research', 'system'].includes(String(candidate.source))
    ? candidate.source as SharedReviewChangeSource
    : sourceFromOrigin(transaction.origin);
  const semanticKind = boundedString(candidate?.semanticKind, 120);
  const operationId = validId(candidate?.operationId) ? candidate.operationId : undefined;
  return { source, ...(semanticKind ? { semanticKind } : {}), ...(operationId ? { operationId } : {}) };
}

function commentApproxBytes(comment: SharedReviewComment): number {
  return 512 + comment.body.length * 2 + (comment.quotedText?.length ?? 0) * 2
    + comment.replies.reduce((total, reply) => total + 256 + reply.body.length * 2, 0);
}

function changeApproxBytes(change: SharedReviewChange): number {
  return 768 + (change.originalText.length + change.proposedText.length + (change.contextBefore?.length ?? 0) + (change.contextAfter?.length ?? 0)) * 2
    + change.replies.reduce((total, reply) => total + 256 + reply.body.length * 2, 0);
}

export class CollaborationReviewStore {
  readonly comments: Y.Map<Y.Map<unknown>>;
  readonly changes: Y.Map<Y.Map<unknown>>;
  readonly events: Y.Array<SharedReviewEvent>;
  private trackChangesEnabled = false;
  private readonly snapshots = new Map<string, string>();
  private readonly textCleanups = new Map<string, () => void>();
  private readonly coalesced = new Map<string, CoalescedChange>();
  private readonly pendingUndoChangeIds = new Map<string, Set<string>>();
  private readonly filesObserver: (event: Y.YMapEvent<Y.Text>) => void;

  constructor(
    private readonly doc: Y.Doc,
    private readonly files: Y.Map<Y.Text>,
    private readonly getLocalUser: () => CollaborationUser | undefined,
    private readonly onCoalescingBoundary?: (path?: string) => void,
  ) {
    this.comments = doc.getMap<Y.Map<unknown>>('review-comments');
    this.changes = doc.getMap<Y.Map<unknown>>('review-changes');
    this.events = doc.getArray<SharedReviewEvent>('review-events');
    for (const [path, text] of files.entries()) this.attachText(path, text);
    this.filesObserver = (event) => {
      for (const key of event.keysChanged) {
        const cleanup = this.textCleanups.get(key);
        cleanup?.();
        this.textCleanups.delete(key);
        this.snapshots.delete(key);
        const text = this.files.get(key);
        if (text) this.attachText(key, text);
        this.coalesced.delete(key);
        this.pendingUndoChangeIds.delete(key);
      }
    };
    files.observe(this.filesObserver);
  }

  destroy(): void {
    this.files.unobserve(this.filesObserver);
    for (const cleanup of this.textCleanups.values()) cleanup();
    this.textCleanups.clear();
    this.snapshots.clear();
    this.coalesced.clear();
    this.pendingUndoChangeIds.clear();
  }

  setTrackChangesEnabled(enabled: boolean): void {
    if (this.trackChangesEnabled === enabled) return;
    this.trackChangesEnabled = enabled;
    this.coalesced.clear();
  }

  getTrackChangesEnabled(): boolean { return this.trackChangesEnabled; }

  stopCoalescing(path?: string): void {
    if (path) this.coalesced.delete(path);
    else this.coalesced.clear();
    this.onCoalescingBoundary?.(path);
  }

  subscribe(listener: (snapshot: SharedReviewSnapshot) => void): () => void {
    let active = true;
    let scheduled = false;
    const notify = () => {
      if (!active || scheduled) return;
      scheduled = true;
      queueMicrotask(() => {
        scheduled = false;
        if (active) listener(this.snapshot());
      });
    };
    const comments = () => notify();
    const changes = () => notify();
    const events = () => notify();
    this.comments.observeDeep(comments);
    this.changes.observeDeep(changes);
    this.events.observe(events);
    return () => {
      active = false;
      this.comments.unobserveDeep(comments);
      this.changes.unobserveDeep(changes);
      this.events.unobserve(events);
    };
  }

  getSnapshot(): SharedReviewSnapshot { return this.snapshot(); }

  snapshot(): SharedReviewSnapshot {
    const comments: SharedReviewComment[] = [];
    const changes: SharedReviewChange[] = [];
    let approximateBytes = 0;

    // Prefer unresolved review state when a hostile or unusually long session
    // approaches the bounded snapshot budget. Old resolved history is useful,
    // but it must never crowd active review work out of the UI snapshot first.
    const commentEntries = [...this.comments.entries()].sort(([, a], [, b]) => {
      const aActive = a.get('status') !== 'resolved' ? 0 : 1;
      const bActive = b.get('status') !== 'resolved' ? 0 : 1;
      return aActive - bActive || Number(b.get('updatedAt') ?? 0) - Number(a.get('updatedAt') ?? 0);
    });
    const changeEntries = [...this.changes.entries()].sort(([, a], [, b]) => {
      const aActive = a.get('state') === 'pending' || a.get('state') === 'conflicted' ? 0 : 1;
      const bActive = b.get('state') === 'pending' || b.get('state') === 'conflicted' ? 0 : 1;
      return aActive - bActive || Number(b.get('updatedAt') ?? 0) - Number(a.get('updatedAt') ?? 0);
    });

    for (const [id, map] of commentEntries) {
      if (comments.length >= COMMENT_THREAD_MAX || approximateBytes >= REVIEW_SNAPSHOT_BYTES_MAX) break;
      const parsed = this.readComment(id, map);
      if (!parsed) continue;
      const bytes = commentApproxBytes(parsed);
      if (approximateBytes + bytes > REVIEW_SNAPSHOT_BYTES_MAX) continue;
      approximateBytes += bytes;
      comments.push(parsed);
    }
    for (const [id, map] of changeEntries) {
      if (changes.length >= CHANGE_RECORD_MAX || approximateBytes >= REVIEW_SNAPSHOT_BYTES_MAX) break;
      const parsed = this.readChange(id, map);
      if (!parsed) continue;
      const bytes = changeApproxBytes(parsed);
      if (approximateBytes + bytes > REVIEW_SNAPSHOT_BYTES_MAX) continue;
      approximateBytes += bytes;
      changes.push(parsed);
    }
    const events = this.events.toArray().filter((item) => this.isEvent(item)).slice(-REVIEW_EVENT_MAX);
    return {
      comments: comments.sort((a, b) => b.updatedAt - a.updatedAt),
      changes: changes.sort((a, b) => a.createdAt - b.createdAt),
      events: events.sort((a, b) => a.createdAt - b.createdAt),
    };
  }

  createComment(input: { filePath?: string; from?: number; to?: number; body: string; scope?: SharedReviewComment['scope']; quotedText?: string }): SharedReviewComment {
    const body = input.body.trim();
    if (!body || body.length > COMMENT_BODY_MAX) throw new Error('Comment is empty or too long');
    this.compactResolvedHistory();
    if (this.comments.size >= COMMENT_THREAD_MAX) throw new Error('Collaborative review comment limit reached; resolve or remove older threads before adding another.');
    const author = authorFromUser(this.getLocalUser());
    const now = Date.now();
    const id = randomId();
    const map = new Y.Map<unknown>();
    const replies = new Y.Array<SharedReviewReply>();
    map.set('version', COLLABORATION_REVIEW_SCHEMA_VERSION);
    map.set('id', id);
    map.set('scope', input.scope ?? (input.filePath ? 'anchor' : 'project'));
    map.set('author', author);
    map.set('body', body);
    map.set('status', 'open');
    map.set('createdAt', now);
    map.set('updatedAt', now);
    map.set('replies', replies);
    if (input.filePath) {
      if (!validFilePath(input.filePath)) throw new Error('Invalid comment file path');
      const text = this.files.get(input.filePath);
      if (!text) throw new Error('Comment target is not a collaborative text file');
      const from = Math.max(0, Math.min(text.length, Math.trunc(input.from ?? 0)));
      const to = Math.max(from, Math.min(text.length, Math.trunc(input.to ?? from)));
      const anchor = encodeAnchor(text, from, to);
      map.set('filePath', input.filePath);
      map.set('anchorStart', anchor.start);
      map.set('anchorEnd', anchor.end);
      const quote = (input.quotedText ?? text.toString().slice(from, to)).slice(0, COMMENT_BODY_MAX);
      if (quote) map.set('quotedText', quote);
    }
    this.doc.transact(() => {
      this.comments.set(id, map);
      this.appendEvent({ kind: 'comment-created', author, summary: 'Source comment created', threadId: id, ...(input.filePath ? { filePath: input.filePath } : {}) });
    }, COLLABORATION_REVIEW_MAINTENANCE_ORIGIN);
    return this.readComment(id, map)!;
  }

  reply(id: string, body: string): boolean {
    const text = body.trim();
    if (!text || text.length > REPLY_BODY_MAX) return false;
    const map = this.comments.get(id) ?? this.changes.get(id);
    if (!map) return false;
    const replies = map.get('replies');
    if (!(replies instanceof Y.Array) || replies.length >= REPLY_MAX) return false;
    const author = authorFromUser(this.getLocalUser());
    const now = Date.now();
    const reply: SharedReviewReply = { id: randomId(), author, body: text, createdAt: now, updatedAt: now };
    this.doc.transact(() => {
      replies.push([reply]);
      map.set('updatedAt', now);
      this.appendEvent({ kind: 'comment-replied', author, summary: 'Replied to review thread', threadId: id, ...(typeof map.get('filePath') === 'string' ? { filePath: String(map.get('filePath')) } : {}) });
    }, COLLABORATION_REVIEW_MAINTENANCE_ORIGIN);
    return true;
  }

  editOwnComment(id: string, body: string): boolean {
    const text = body.trim();
    if (!text || text.length > COMMENT_BODY_MAX) throw new Error('Comment is empty or too long');
    const map = this.comments.get(id);
    if (!map) return false;
    const author = authorFromUser(this.getLocalUser());
    const existingAuthor = map.get('author');
    if (!isAuthor(existingAuthor) || existingAuthor.id !== author.id) throw new Error('Only your own comment can be edited');
    this.doc.transact(() => {
      map.set('body', text);
      map.set('updatedAt', Date.now());
      this.appendEvent({ kind: 'comment-edited', author, summary: 'Comment edited', threadId: id, ...(typeof map.get('filePath') === 'string' ? { filePath: String(map.get('filePath')) } : {}) });
    }, COLLABORATION_REVIEW_MAINTENANCE_ORIGIN);
    return true;
  }

  deleteOwnComment(id: string): boolean {
    const map = this.comments.get(id);
    if (!map) return false;
    const author = authorFromUser(this.getLocalUser());
    const existingAuthor = map.get('author');
    if (!isAuthor(existingAuthor) || existingAuthor.id !== author.id) throw new Error('Only your own comment can be deleted');
    const filePath = typeof map.get('filePath') === 'string' ? String(map.get('filePath')) : undefined;
    this.doc.transact(() => {
      this.comments.delete(id);
      this.appendEvent({ kind: 'comment-deleted', author, summary: 'Comment deleted', threadId: id, ...(filePath ? { filePath } : {}) });
    }, COLLABORATION_REVIEW_MAINTENANCE_ORIGIN);
    return true;
  }

  setCommentResolved(id: string, resolved: boolean): boolean {
    const map = this.comments.get(id);
    if (!map) return false;
    const author = authorFromUser(this.getLocalUser());
    const now = Date.now();
    const filePath = typeof map.get('filePath') === 'string' ? String(map.get('filePath')) : undefined;
    this.doc.transact(() => {
      map.set('status', resolved ? 'resolved' : 'open');
      map.set('updatedAt', now);
      this.appendEvent({ kind: resolved ? 'comment-resolved' : 'comment-reopened', author, summary: resolved ? 'Comment resolved' : 'Comment reopened', threadId: id, ...(filePath ? { filePath } : {}) });
    }, COLLABORATION_REVIEW_MAINTENANCE_ORIGIN);
    this.compactResolvedHistory();
    return true;
  }

  /**
   * Publish one shared Review proposal without mutating collaborative Y.Text.
   *
   * This is intentionally separate from normal Track Changes. Human typing
   * remains edit-first, while AI proposals stay source-authoritative until an
   * explicit Accept applies the proposed text.
   */
  proposeDeferredChange(input: {
    filePath: string;
    from: number;
    to: number;
    originalText: string;
    proposedText: string;
    author: SharedReviewAuthor;
    source?: 'ai' | 'system';
    semanticKind?: string;
    operationId?: string;
    metadata?: SharedReviewChangeMetadata;
  }): SharedReviewChange {
    const filePath = input.filePath;
    const text = this.files.get(filePath);
    if (!validFilePath(filePath) || !text) throw new Error('Deferred review proposal targets an unavailable collaborative file');
    if (!isAuthor(input.author)) throw new Error('Deferred review proposal has an invalid author');
    if (input.originalText.length > CHANGE_TEXT_MAX || input.proposedText.length > CHANGE_TEXT_MAX) throw new Error('Deferred review proposal is too large');
    const from = Math.max(0, Math.min(text.length, Math.trunc(input.from)));
    const to = Math.max(from, Math.min(text.length, Math.trunc(input.to)));
    if (text.toString().slice(from, to) !== input.originalText) throw new Error('Deferred review proposal is outdated');
    if (input.originalText === input.proposedText) throw new Error('Deferred review proposal does not change the source');
    const metadata = input.metadata === undefined ? undefined : reviewChangeMetadata(input.metadata);
    if (input.metadata !== undefined && metadata === undefined) throw new Error('Deferred review proposal metadata is invalid');
    const semanticKind = boundedOptionalString(input.semanticKind, 120);
    const operationId = input.operationId === undefined ? undefined : (validId(input.operationId) ? input.operationId : undefined);
    if (input.operationId !== undefined && !operationId) throw new Error('Deferred review proposal operation id is invalid');
    const type = changeType(input.originalText, input.proposedText);
    const now = Date.now();
    const id = randomId();
    const anchor = encodeAnchor(text, from, to);
    const current = text.toString();
    const map = new Y.Map<unknown>();
    map.set('version', COLLABORATION_REVIEW_SCHEMA_VERSION);
    map.set('id', id);
    map.set('filePath', filePath);
    map.set('type', type);
    map.set('author', structuredClone(input.author));
    map.set('originalText', input.originalText);
    map.set('proposedText', input.proposedText);
    map.set('createdAt', now);
    map.set('updatedAt', now);
    map.set('state', 'pending');
    map.set('source', input.source ?? 'ai');
    map.set('applicationMode', 'deferred');
    if (semanticKind) map.set('semanticKind', semanticKind);
    if (operationId) map.set('operationId', operationId);
    if (metadata) map.set('metadata', structuredClone(metadata));
    map.set('anchorStart', anchor.start);
    map.set('anchorEnd', anchor.end);
    map.set('contextBefore', current.slice(Math.max(0, from - CHANGE_CONTEXT_MAX), from));
    map.set('contextAfter', current.slice(to, to + CHANGE_CONTEXT_MAX));
    map.set('replies', new Y.Array<SharedReviewReply>());
    this.compactResolvedHistory();
    this.doc.transact(() => {
      this.changes.set(id, map);
      this.appendEvent({
        kind: 'change-proposed',
        author: input.author,
        summary: `AI proposed ${type} in ${filePath}`,
        threadId: id,
        filePath,
        changeType: type,
        source: input.source ?? 'ai',
        ...(semanticKind ? { semanticKind } : {}),
        ...(operationId ? { operationId } : {}),
      });
    }, COLLABORATION_REVIEW_MAINTENANCE_ORIGIN);
    const created = this.changes.get(id);
    const parsed = created ? this.readChange(id, created) : undefined;
    if (!parsed) throw new Error('Deferred review proposal could not be persisted');
    return parsed;
  }

  acceptChange(id: string): boolean {
    const map = this.changes.get(id);
    const change = map ? this.readChange(id, map) : undefined;
    if (!map || !change || change.state !== 'pending') return false;
    const author = authorFromUser(this.getLocalUser());
    if (change.applicationMode === 'deferred') {
      const text = this.files.get(change.filePath);
      if (!text || change.from === undefined || change.to === undefined) {
        this.markConflicted(map, change, 'Deferred review proposal anchor is no longer available');
        return false;
      }
      if (text.toString().slice(change.from, change.to) !== change.originalText) {
        this.markConflicted(map, change, 'Deferred review proposal overlaps newer edits');
        return false;
      }
      this.doc.transact(() => {
        if (change.to! > change.from!) text.delete(change.from!, change.to! - change.from!);
        if (change.proposedText) text.insert(change.from!, change.proposedText);
        map.set('state', 'accepted');
        map.set('updatedAt', Date.now());
        this.appendEvent({ kind: 'change-accepted', author, summary: 'AI review suggestion accepted', threadId: id, filePath: change.filePath, changeType: change.type, source: change.source });
      }, COLLABORATION_REVIEW_MAINTENANCE_ORIGIN);
      this.stopCoalescing(change.filePath);
      this.compactResolvedHistory();
      return true;
    }
    const source = change.source;
    this.doc.transact(() => {
      map.set('state', 'accepted');
      map.set('updatedAt', Date.now());
      this.appendEvent({ kind: 'change-accepted', author, summary: 'Tracked change accepted', threadId: id, filePath: String(map.get('filePath')), changeType: map.get('type') as SharedReviewChangeType, source });
    }, COLLABORATION_REVIEW_MAINTENANCE_ORIGIN);
    this.stopCoalescing(String(map.get('filePath')));
    this.compactResolvedHistory();
    return true;
  }

  rejectChange(id: string): boolean {
    const map = this.changes.get(id);
    const change = map ? this.readChange(id, map) : undefined;
    if (!map || !change || change.state !== 'pending') return false;
    if (change.applicationMode === 'deferred') {
      const author = authorFromUser(this.getLocalUser());
      this.doc.transact(() => {
        map.set('state', 'rejected');
        map.set('updatedAt', Date.now());
        this.appendEvent({ kind: 'change-rejected', author, summary: 'AI review suggestion rejected', threadId: id, filePath: change.filePath, changeType: change.type, source: change.source });
      }, COLLABORATION_REVIEW_MAINTENANCE_ORIGIN);
      this.stopCoalescing(change.filePath);
      this.compactResolvedHistory();
      return true;
    }
    const text = this.files.get(change.filePath);
    if (!text || change.from === undefined || change.to === undefined) {
      this.markConflicted(map, change, 'Tracked change anchor is no longer available');
      return false;
    }
    let canApply = false;
    let edit: { from: number; to: number; text: string } | undefined;
    if (change.type === 'insert') {
      canApply = text.toString().slice(change.from, change.to) === change.proposedText;
      if (canApply) edit = { from: change.from, to: change.to, text: '' };
    } else if (change.type === 'replace') {
      canApply = text.toString().slice(change.from, change.to) === change.proposedText;
      if (canApply) edit = { from: change.from, to: change.to, text: change.originalText };
    } else {
      const current = text.toString();
      const alreadyRestored = current.slice(change.from, change.from + change.originalText.length) === change.originalText;
      const contextBefore = change.contextBefore ?? '';
      const contextAfter = change.contextAfter ?? '';
      const beforeMatches = !contextBefore || current.slice(Math.max(0, change.from - contextBefore.length), change.from) === contextBefore;
      const afterMatches = !contextAfter || current.slice(change.from, change.from + contextAfter.length) === contextAfter;
      canApply = !alreadyRestored && beforeMatches && afterMatches;
      if (canApply) edit = { from: change.from, to: change.from, text: change.originalText };
    }
    if (!canApply || !edit) {
      this.markConflicted(map, change, 'Tracked change overlaps newer edits');
      return false;
    }
    const author = authorFromUser(this.getLocalUser());
    this.doc.transact(() => {
      if (edit!.to > edit!.from) text.delete(edit!.from, edit!.to - edit!.from);
      if (edit!.text) text.insert(edit!.from, edit!.text);
      map.set('state', 'rejected');
      map.set('updatedAt', Date.now());
      this.appendEvent({ kind: 'change-rejected', author, summary: 'Tracked change rejected', threadId: id, filePath: change.filePath, changeType: change.type, source: change.source });
    }, COLLABORATION_REVIEW_MAINTENANCE_ORIGIN);
    this.stopCoalescing(change.filePath);
    this.compactResolvedHistory();
    return true;
  }

  dismissConflict(id: string): boolean {
    const map = this.changes.get(id);
    const change = map ? this.readChange(id, map) : undefined;
    if (!map || !change || change.state !== 'conflicted') return false;
    const author = authorFromUser(this.getLocalUser());
    if (change.applicationMode === 'deferred') {
      this.doc.transact(() => {
        map.set('state', 'rejected');
        map.set('updatedAt', Date.now());
        this.appendEvent({ kind: 'change-rejected', author, summary: 'Conflicted AI suggestion dismissed; current text kept', threadId: id, filePath: change.filePath, changeType: change.type, source: change.source });
      }, COLLABORATION_REVIEW_MAINTENANCE_ORIGIN);
      this.stopCoalescing(change.filePath);
      this.compactResolvedHistory();
      return true;
    }
    this.doc.transact(() => {
      map.set('state', 'accepted');
      map.set('updatedAt', Date.now());
      this.appendEvent({ kind: 'change-accepted', author, summary: 'Conflicted tracked change dismissed; current text kept', threadId: id, filePath: change.filePath, changeType: change.type, source: change.source });
    }, COLLABORATION_REVIEW_MAINTENANCE_ORIGIN);
    this.stopCoalescing(change.filePath);
    this.compactResolvedHistory();
    return true;
  }

  resolveChangeKeepingCurrent(id: string): boolean { return this.dismissConflict(id); }

  resolveChangeRestoringOriginal(id: string): boolean {
    const map = this.changes.get(id);
    const change = map ? this.readChange(id, map) : undefined;
    if (!map || !change || change.state !== 'conflicted') return false;
    if (change.applicationMode === 'deferred') return this.dismissConflict(id);
    const text = this.files.get(change.filePath);
    if (!text || change.from === undefined || change.to === undefined) return false;
    const from = change.from;
    const to = change.to;
    const author = authorFromUser(this.getLocalUser());
    this.doc.transact(() => {
      if (to > from) text.delete(from, to - from);
      if (change.originalText) text.insert(from, change.originalText);
      map.set('state', 'rejected');
      map.set('updatedAt', Date.now());
      this.appendEvent({ kind: 'change-rejected', author, summary: 'Conflicted tracked change restored to original text', threadId: id, filePath: change.filePath, changeType: change.type, source: change.source });
    }, COLLABORATION_REVIEW_MAINTENANCE_ORIGIN);
    this.stopCoalescing(change.filePath);
    this.compactResolvedHistory();
    return true;
  }

  markUndoChange(path: string, id: string): void {
    const ids = this.pendingUndoChangeIds.get(path) ?? new Set<string>();
    ids.add(id);
    this.pendingUndoChangeIds.set(path, ids);
  }

  consumeUndoChangeIds(path: string): string[] {
    const ids = [...(this.pendingUndoChangeIds.get(path) ?? [])];
    this.pendingUndoChangeIds.delete(path);
    return ids;
  }

  captureUndoRecords(path: string, ids: readonly string[]): SharedReviewUndoSnapshot {
    const localId = this.getLocalUser()?.id;
    const records: Array<Record<string, unknown>> = [];
    if (!localId) return { records };
    for (const id of ids) {
      const map = this.changes.get(id);
      if (!map || map.get('state') !== 'pending' || map.get('filePath') !== path) continue;
      const author = map.get('author');
      if (!isAuthor(author) || author.id !== localId) continue;
      records.push({ id, ...cloneRecord(map) });
    }
    return { records };
  }

  removeUndoRecords(path: string, snapshot: SharedReviewUndoSnapshot): void {
    this.doc.transact(() => {
      for (const record of snapshot.records) {
        const id = typeof record.id === 'string' ? record.id : undefined;
        const map = id ? this.changes.get(id) : undefined;
        if (!id || !map || map.get('filePath') !== path || map.get('state') !== 'pending') continue;
        this.changes.delete(id);
      }
    }, COLLABORATION_REVIEW_MAINTENANCE_ORIGIN);
    this.stopCoalescing(path);
  }

  restoreUndoRecords(path: string, snapshot: SharedReviewUndoSnapshot): void {
    this.doc.transact(() => {
      for (const record of snapshot.records) {
        const id = typeof record.id === 'string' ? record.id : undefined;
        if (!id || this.changes.has(id) || record.filePath !== path) continue;
        const map = new Y.Map<unknown>();
        const copy = { ...record };
        delete copy.id;
        applyRecord(map, copy);
        this.changes.set(id, map);
      }
    }, COLLABORATION_REVIEW_MAINTENANCE_ORIGIN);
    this.stopCoalescing(path);
  }

  private attachText(path: string, text: Y.Text): void {
    this.snapshots.set(path, text.toString());
    const observer = (event: Y.YTextEvent, transaction: Y.Transaction) => {
      const before = this.snapshots.get(path) ?? '';
      const after = text.toString();
      this.snapshots.set(path, after);
      if (!transaction.local) {
        this.stopCoalescing(path);
        return;
      }
      if (!this.trackChangesEnabled || transaction.origin === COLLABORATION_REVIEW_MAINTENANCE_ORIGIN || transaction.origin instanceof Y.UndoManager) return;
      const edits = deltaEdits(before, event.delta);
      if (!edits.length) return;
      const mutation = reviewMutationMeta(transaction);
      const source = mutation.source;
      if (source !== 'typing') this.stopCoalescing(path);
      const operationId = mutation.operationId ?? (edits.length > 1 ? randomId() : undefined);
      for (const edit of edits) {
        this.recordTrackedEdit(path, text, edit, source, { ...mutation, ...(operationId ? { operationId } : {}) });
        if (source !== 'typing' || edit.originalText.includes('\n') || edit.proposedText.includes('\n')) this.stopCoalescing(path);
      }
    };
    text.observe(observer);
    this.textCleanups.set(path, () => text.unobserve(observer));
  }

  private recordTrackedEdit(path: string, text: Y.Text, edit: TrackedDeltaEdit, source: SharedReviewChangeSource, mutation: CollaborationReviewMutationMeta): void {
    if (!validFilePath(path) || edit.originalText.length > CHANGE_TEXT_MAX || edit.proposedText.length > CHANGE_TEXT_MAX) return;
    const author = authorFromUser(this.getLocalUser());
    if (!validId(author.id)) return;
    const type = changeType(edit.originalText, edit.proposedText);
    const now = Date.now();
    const previous = this.coalesced.get(path);
    if (previous && previous.authorId === author.id && now - previous.lastAt <= COALESCE_MS) {
      const map = this.changes.get(previous.id);
      const parsed = map ? this.readChange(previous.id, map) : undefined;
      // Typing over a selection normally emits one replace delta for the first
      // character followed by insert deltas. Keep that as one meaningful
      // replacement suggestion instead of splitting the replacement by key.
      if (previous.type === 'replace' && type === 'insert' && map && parsed?.state === 'pending' && parsed.to !== undefined && parsed.to === edit.newFrom) {
        const anchor = encodeAnchor(text, parsed.from ?? edit.newFrom, edit.newTo);
        this.doc.transact(() => {
          map.set('anchorStart', anchor.start);
          map.set('anchorEnd', anchor.end);
          map.set('proposedText', `${parsed.proposedText}${edit.proposedText}`);
          map.set('updatedAt', now);
        }, COLLABORATION_REVIEW_MAINTENANCE_ORIGIN);
        this.markUndoChange(path, previous.id);
        previous.lastAt = now;
        previous.lastOldFrom = edit.oldFrom;
        return;
      }
      if (previous.type !== type) {
        this.coalesced.delete(path);
      } else {
        if (type === 'insert' && map && parsed?.state === 'pending' && parsed.to !== undefined && parsed.to === edit.newFrom) {
          const anchor = encodeAnchor(text, parsed.from ?? edit.newFrom, edit.newTo);
          this.doc.transact(() => {
            map.set('anchorStart', anchor.start);
            map.set('anchorEnd', anchor.end);
            map.set('proposedText', `${parsed.proposedText}${edit.proposedText}`);
            map.set('updatedAt', now);
          }, COLLABORATION_REVIEW_MAINTENANCE_ORIGIN);
          this.markUndoChange(path, previous.id);
          previous.lastAt = now;
          previous.lastOldFrom = edit.oldFrom;
          return;
        }
        if (type === 'delete' && map && parsed?.state === 'pending' && parsed.from !== undefined && parsed.from === edit.newFrom) {
          const anchor = encodeAnchor(text, edit.newFrom, edit.newFrom);
          const deletingBackward = edit.oldFrom < previous.lastOldFrom;
          const currentText = text.toString();
          this.doc.transact(() => {
            map.set('anchorStart', anchor.start);
            map.set('anchorEnd', anchor.end);
            map.set('originalText', deletingBackward ? `${edit.originalText}${parsed.originalText}` : `${parsed.originalText}${edit.originalText}`);
            map.set('contextBefore', currentText.slice(Math.max(0, edit.newFrom - CHANGE_CONTEXT_MAX), edit.newFrom));
            map.set('contextAfter', currentText.slice(edit.newFrom, edit.newFrom + CHANGE_CONTEXT_MAX));
            map.set('updatedAt', now);
          }, COLLABORATION_REVIEW_MAINTENANCE_ORIGIN);
          this.markUndoChange(path, previous.id);
          previous.lastAt = now;
          previous.lastOldFrom = edit.oldFrom;
          return;
        }
      }
    }
    this.compactResolvedHistory();
    const id = randomId();
    const anchor = encodeAnchor(text, edit.newFrom, edit.newTo);
    const map = new Y.Map<unknown>();
    map.set('version', COLLABORATION_REVIEW_SCHEMA_VERSION);
    map.set('id', id);
    map.set('filePath', path);
    map.set('type', type);
    map.set('author', author);
    map.set('originalText', edit.originalText);
    map.set('proposedText', edit.proposedText);
    map.set('createdAt', now);
    map.set('updatedAt', now);
    map.set('state', 'pending');
    map.set('source', source);
    map.set('applicationMode', 'applied');
    if (mutation.semanticKind) map.set('semanticKind', mutation.semanticKind);
    if (mutation.operationId) map.set('operationId', mutation.operationId);
    map.set('anchorStart', anchor.start);
    map.set('anchorEnd', anchor.end);
    if (type === 'delete') {
      const currentText = text.toString();
      map.set('contextBefore', currentText.slice(Math.max(0, edit.newFrom - CHANGE_CONTEXT_MAX), edit.newFrom));
      map.set('contextAfter', currentText.slice(edit.newFrom, edit.newFrom + CHANGE_CONTEXT_MAX));
    }
    map.set('replies', new Y.Array<SharedReviewReply>());
    this.doc.transact(() => {
      this.changes.set(id, map);
      this.appendEvent({ kind: 'change-proposed', author, summary: `Proposed ${type} in ${path}`, threadId: id, filePath: path, changeType: type, source, ...(mutation.semanticKind ? { semanticKind: mutation.semanticKind } : {}), ...(mutation.operationId ? { operationId: mutation.operationId } : {}) });
    }, COLLABORATION_REVIEW_MAINTENANCE_ORIGIN);
    this.coalesced.set(path, { id, authorId: author.id, type, lastAt: now, lastOldFrom: edit.oldFrom });
    this.markUndoChange(path, id);
  }

  compactResolvedHistory(): void {
    const resolvedComments = [...this.comments.entries()]
      .filter(([, map]) => map.get('status') === 'resolved')
      .sort(([, a], [, b]) => Number(a.get('updatedAt') ?? 0) - Number(b.get('updatedAt') ?? 0));
    const resolvedChanges = [...this.changes.entries()]
      .filter(([, map]) => map.get('state') === 'accepted' || map.get('state') === 'rejected')
      .sort(([, a], [, b]) => Number(a.get('updatedAt') ?? 0) - Number(b.get('updatedAt') ?? 0));
    const commentOverflow = Math.max(0, this.comments.size - COMMENT_THREAD_MAX, resolvedComments.length - RESOLVED_COMMENT_RETAIN);
    const changeOverflow = Math.max(0, this.changes.size - CHANGE_RECORD_MAX, resolvedChanges.length - RESOLVED_CHANGE_RETAIN);
    if (!commentOverflow && !changeOverflow) return;
    this.doc.transact(() => {
      for (const [id] of resolvedComments.slice(0, commentOverflow)) this.comments.delete(id);
      for (const [id] of resolvedChanges.slice(0, changeOverflow)) this.changes.delete(id);
    }, COLLABORATION_REVIEW_MAINTENANCE_ORIGIN);
  }

  private readComment(id: string, map: Y.Map<unknown>): SharedReviewComment | undefined {
    if (!validId(id) || map.get('version') !== COLLABORATION_REVIEW_SCHEMA_VERSION) return undefined;
    const author = map.get('author');
    const body = boundedString(map.get('body'), COMMENT_BODY_MAX);
    const scope = map.get('scope');
    const status = map.get('status');
    const createdAt = map.get('createdAt');
    const updatedAt = map.get('updatedAt');
    if (!isAuthor(author) || body === undefined || !['anchor', 'document', 'project'].includes(String(scope)) || !['open', 'resolved', 'outdated'].includes(String(status)) || !validTimestamp(createdAt) || !validTimestamp(updatedAt)) return undefined;
    const filePath = map.get('filePath');
    let range: { from: number; to: number } | undefined;
    let anchorRemoved = false;
    if (filePath !== undefined) {
      if (!validFilePath(filePath) || !this.files.has(filePath)) return undefined;
      const expectedText = this.files.get(filePath);
      if (!expectedText) return undefined;
      range = resolveEncodedAnchor(this.doc, map.get('anchorStart'), map.get('anchorEnd'), expectedText);
      if (!range) anchorRemoved = true;
      const quoted = boundedString(map.get('quotedText'), COMMENT_BODY_MAX);
      if (range && quoted && range.from === range.to) anchorRemoved = true;
    }
    const repliesValue = map.get('replies');
    const replies = repliesValue instanceof Y.Array
      ? repliesValue.toArray().filter((reply): reply is SharedReviewReply => this.isReply(reply)).slice(-REPLY_MAX)
      : [];
    const quotedText = boundedString(map.get('quotedText'), COMMENT_BODY_MAX);
    return {
      id,
      version: COLLABORATION_REVIEW_SCHEMA_VERSION,
      ...(typeof filePath === 'string' ? { filePath } : {}),
      scope: scope as SharedReviewComment['scope'],
      author,
      body,
      ...(quotedText ? { quotedText } : {}),
      createdAt,
      updatedAt,
      status: anchorRemoved && status === 'open' ? 'outdated' : status as SharedReviewComment['status'],
      ...(range ? { from: range.from, to: range.to } : {}),
      ...(anchorRemoved ? { anchorRemoved: true } : {}),
      replies,
    };
  }

  private readChange(id: string, map: Y.Map<unknown>): SharedReviewChange | undefined {
    if (!validId(id) || map.get('version') !== COLLABORATION_REVIEW_SCHEMA_VERSION) return undefined;
    const filePath = map.get('filePath');
    const type = map.get('type');
    const author = map.get('author');
    const originalText = boundedString(map.get('originalText'), CHANGE_TEXT_MAX);
    const proposedText = boundedString(map.get('proposedText'), CHANGE_TEXT_MAX);
    const state = map.get('state');
    const source = map.get('source');
    const applicationMode = map.get('applicationMode') ?? 'applied';
    const createdAt = map.get('createdAt');
    const updatedAt = map.get('updatedAt');
    if (!validFilePath(filePath) || !this.files.has(filePath) || !['insert', 'delete', 'replace'].includes(String(type)) || !isAuthor(author) || originalText === undefined || proposedText === undefined || !['pending', 'accepted', 'rejected', 'conflicted'].includes(String(state)) || !['typing', 'toolbar', 'ai', 'citation', 'paper-memory', 'tikz', 'terminal', 'research', 'system'].includes(String(source)) || !['applied', 'deferred'].includes(String(applicationMode)) || !validTimestamp(createdAt) || !validTimestamp(updatedAt)) return undefined;
    const expectedText = this.files.get(filePath);
    if (!expectedText) return undefined;
    const range = resolveEncodedAnchor(this.doc, map.get('anchorStart'), map.get('anchorEnd'), expectedText);
    const semanticKind = boundedString(map.get('semanticKind'), 120);
    const operationId = validId(map.get('operationId')) ? String(map.get('operationId')) : undefined;
    const metadataValue = map.get('metadata');
    const metadata = metadataValue === undefined ? undefined : reviewChangeMetadata(metadataValue);
    if (metadataValue !== undefined && metadata === undefined) return undefined;
    const contextBefore = boundedString(map.get('contextBefore'), CHANGE_CONTEXT_MAX);
    const contextAfter = boundedString(map.get('contextAfter'), CHANGE_CONTEXT_MAX);
    const repliesValue = map.get('replies');
    const replies = repliesValue instanceof Y.Array
      ? repliesValue.toArray().filter((reply): reply is SharedReviewReply => this.isReply(reply)).slice(-REPLY_MAX)
      : [];
    return {
      id,
      version: COLLABORATION_REVIEW_SCHEMA_VERSION,
      filePath,
      type: type as SharedReviewChangeType,
      author,
      originalText,
      proposedText,
      createdAt,
      updatedAt,
      state: state as SharedReviewChangeState,
      source: source as SharedReviewChangeSource,
      applicationMode: applicationMode as SharedReviewChangeApplicationMode,
      ...(semanticKind ? { semanticKind } : {}),
      ...(metadata ? { metadata } : {}),
      ...(operationId ? { operationId } : {}),
      ...(contextBefore !== undefined ? { contextBefore } : {}),
      ...(contextAfter !== undefined ? { contextAfter } : {}),
      ...(range ? { from: range.from, to: range.to } : { anchorRemoved: true }),
      replies,
    };
  }

  private isReply(value: unknown): value is SharedReviewReply {
    if (!value || typeof value !== 'object') return false;
    const reply = value as Partial<SharedReviewReply>;
    return validId(reply.id) && isAuthor(reply.author) && typeof reply.body === 'string' && reply.body.length <= REPLY_BODY_MAX && validTimestamp(reply.createdAt) && validTimestamp(reply.updatedAt);
  }

  private isEvent(value: unknown): value is SharedReviewEvent {
    if (!value || typeof value !== 'object') return false;
    const event = value as Partial<SharedReviewEvent>;
    return validId(event.id)
      && event.version === COLLABORATION_REVIEW_SCHEMA_VERSION
      && ['comment-created', 'comment-replied', 'comment-edited', 'comment-deleted', 'comment-resolved', 'comment-reopened', 'change-proposed', 'change-accepted', 'change-rejected', 'change-conflicted'].includes(String(event.kind))
      && isAuthor(event.author)
      && validTimestamp(event.createdAt)
      && typeof event.summary === 'string'
      && event.summary.length <= 500
      && (event.semanticKind === undefined || (typeof event.semanticKind === 'string' && event.semanticKind.length <= 120))
      && (event.operationId === undefined || validId(event.operationId));
  }

  private appendEvent(input: Omit<SharedReviewEvent, 'id' | 'version' | 'createdAt'> & { createdAt?: number }): void {
    const event: SharedReviewEvent = { ...input, id: randomId(), version: COLLABORATION_REVIEW_SCHEMA_VERSION, createdAt: input.createdAt ?? Date.now() };
    this.events.push([event]);
    if (this.events.length > REVIEW_EVENT_MAX) this.events.delete(0, this.events.length - REVIEW_EVENT_MAX);
  }

  private markConflicted(map: Y.Map<unknown>, change: SharedReviewChange, summary: string): void {
    const author = authorFromUser(this.getLocalUser());
    this.doc.transact(() => {
      map.set('state', 'conflicted');
      map.set('updatedAt', Date.now());
      this.appendEvent({ kind: 'change-conflicted', author, summary, threadId: change.id, filePath: change.filePath, changeType: change.type, source: change.source });
    }, COLLABORATION_REVIEW_MAINTENANCE_ORIGIN);
    this.stopCoalescing(change.filePath);
  }
}
