import { getBibField } from '@learnitive/bibliography';
import { BaseService, type BaseServiceContext } from '@learnitive/base-utils';
import { SERVICE_NAME } from '@learnitive/constants';
import {
  applySourceTextEdits,
  captureSourceAnchor,
  resolveSourceAnchor,
  type SourceAnchor,
  type SourceTextEdit,
} from '@learnitive/latex-source';
import {
  assetReferenceMatchesFile,
  createSemanticDoctorReport,
  resolveAssetTarget,
  resolveBibliographyTarget,
  resolveInputTarget,
  searchProjectSemanticIndex,
  type ProjectDoctorIssue,
  type ProjectDoctorReport,
  type ProjectSemanticSearchResult,
  type ProjectSemanticSnapshot,
  type SemanticNode,
  type VisualBlock,
} from '@learnitive/project-semantic-index';
import {
  isProjectReadableTextPath,
  projectFilePath,
  type ProjectFilePath,
} from '@learnitive/project-storage';
import { projectModeForProfile } from '@learnitive/project-runtime';
import { WsPath } from '@learnitive/ws-path';
import type { FileSystemService } from './file-system-service';
import type { LatexSourceService } from './latex-source-service';
import type { NavigationService } from './navigation-service';
import type { NoteSnapshotService } from './note-snapshot-service';
import type { ProjectCompileService } from './project-compile-service';
import { buildProjectMeaningChunks, type ProjectMeaningSearchResult, type EmbeddingModelState } from '@learnitive/project-meaning';
import {
  projectRagLexicalScore,
  rankProjectRagCandidates,
  type ProjectRagCandidate,
  type ProjectRagRetrieval,
} from '@learnitive/project-rag';
import type { ProjectMeaningIndexProgress, ProjectMeaningStatus, ProjectSemanticService } from './project-semantic-service';
import type { ProjectStorageService } from './project-storage-service';
import type { ProjectCollaborationService } from './project-collaboration-service';

const IMAGE_EXTENSION = /\.(?:png|svg|eps|pdf)$/i;
const TEX_EXTENSION = /\.(?:tex|ltx)$/i;
const BIB_EXTENSION = /\.bib$/i;
const SAFE_SYMBOL = /^[A-Za-z0-9:._-]+$/;
const MAX_TEXT_SEARCH_FILE_BYTES = 5 * 1024 * 1024;

const SOURCE_DECODER = new TextDecoder();

export type ProjectTextSearchScope =
  | { kind: 'project' }
  | { kind: 'folder'; folder: string }
  | { kind: 'file'; file: ProjectFilePath };

export type ProjectTextSearchOptions = {
  query: string;
  scope?: ProjectTextSearchScope;
  caseSensitive?: boolean;
  wholeWord?: boolean;
  regex?: boolean;
  includeGlobs?: readonly string[];
  excludeGlobs?: readonly string[];
  maxResults?: number;
};

export type ProjectTextSearchMatch = {
  id: string;
  file: ProjectFilePath;
  from: number;
  to: number;
  line: number;
  column: number;
  text: string;
  preview: string;
  anchor: SourceAnchor;
};

export type ProjectTextReplacePreview = {
  matches: readonly ProjectTextSearchMatch[];
  files: readonly { file: ProjectFilePath; matches: number }[];
};


export type CitationAuditIssueKind =
  | 'missing-entry'
  | 'unused-entry'
  | 'duplicate-key'
  | 'duplicate-doi'
  | 'duplicate-work'
  | 'title-mismatch'
  | 'author-year-mismatch'
  | 'possible-renamed-key'
  | 'incomplete-metadata'
  | 'malformed-entry';

export type CitationAuditIssue = {
  id: string;
  kind: CitationAuditIssueKind;
  severity: 'error' | 'warning' | 'info';
  message: string;
  file?: ProjectFilePath;
  line?: number;
  target?: string;
  anchor?: SourceAnchor;
};

export type CitationAuditDoiCandidate = {
  file: ProjectFilePath;
  key: string;
  title: string;
  author?: string;
  year?: number;
};

export type CitationAuditReport = {
  issues: readonly CitationAuditIssue[];
  errors: number;
  warnings: number;
  doiCandidates: readonly CitationAuditDoiCandidate[];
};

export type ProjectResearchReviewFindingKind =
  | 'citation-coverage'
  | 'figure-caption'
  | 'figure-label'
  | 'figure-accessibility'
  | 'table-caption'
  | 'table-label'
  | 'reference-integrity'
  | 'bibliography-integrity';

export type ProjectResearchReviewFinding = {
  id: string;
  kind: ProjectResearchReviewFindingKind;
  severity: 'warning' | 'info';
  title: string;
  message: string;
  file: ProjectFilePath;
  line: number;
  anchor: SourceAnchor;
};

export type ProjectResearchReviewReport = {
  findings: readonly ProjectResearchReviewFinding[];
  filesReviewed: number;
};

function escapeRegExp(value: string): string {
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function globToRegExp(glob: string): RegExp {
  const normalized = glob.replaceAll('\\', '/');
  let out = '^';
  for (let i = 0; i < normalized.length; i += 1) {
    const ch = normalized[i]!;
    if (ch === '*') {
      if (normalized[i + 1] === '*') {
        i += 1;
        if (normalized[i + 1] === '/') {
          i += 1;
          out += '(?:.*/)?';
        } else out += '.*';
      } else out += '[^/]*';
    } else if (ch === '?') out += '[^/]';
    else out += escapeRegExp(ch);
  }
  return new RegExp(`${out}$`);
}

function pathMatchesGlobs(path: string, globs: readonly string[] | undefined): boolean {
  if (!globs?.length) return true;
  const normalizedPath = path.replaceAll('\\', '/');
  const baseName = normalizedPath.split('/').at(-1) ?? normalizedPath;
  return globs.some((glob) => {
    const normalizedGlob = glob.replaceAll('\\', '/');
    const candidate = normalizedGlob.includes('/') ? normalizedPath : baseName;
    return globToRegExp(normalizedGlob).test(candidate);
  });
}

function lineColumnAt(source: string, offset: number): { line: number; column: number } {
  let line = 1;
  let lastBreak = -1;
  for (let i = 0; i < offset; i += 1) {
    if (source.charCodeAt(i) === 10) {
      line += 1;
      lastBreak = i;
    }
  }
  return { line, column: offset - lastBreak };
}

function searchPattern(options: ProjectTextSearchOptions): RegExp {
  if (!options.query) throw new Error('Search query is required');
  let body = options.regex ? options.query : escapeRegExp(options.query);
  if (options.wholeWord) {
    // JavaScript's `\\b` is ASCII-centric even under the Unicode flag. LaTeX
    // projects routinely contain Greek and other scientific identifiers, so
    // use Unicode letter/number boundaries instead.
    body = `(?<![\\p{L}\\p{N}_])(?:${body})(?![\\p{L}\\p{N}_])`;
  }
  try {
    return new RegExp(body, options.caseSensitive ? 'gu' : 'giu');
  } catch (error) {
    throw new Error(`Invalid project search expression: ${error instanceof Error ? error.message : String(error)}`);
  }
}

function expandRegexReplacement(
  source: string,
  match: RegExpExecArray,
  replacement: string,
): string {
  return replacement.replace(
    /\$\$|\$&|\$`|\$'|\$<([A-Za-z][A-Za-z0-9_]*)>|\$(\d{1,2})/g,
    (token, named: string | undefined, numbered: string | undefined) => {
      if (token === '$$') return '$';
      if (token === '$&') return match[0] ?? '';
      if (token === '$`') return source.slice(0, match.index);
      if (token === "$'") return source.slice(match.index + (match[0]?.length ?? 0));
      if (named) return match.groups?.[named] ?? '';
      if (numbered) {
        const index = Number(numbered);
        return Number.isInteger(index) && index > 0 && index < match.length
          ? (match[index] ?? '')
          : token;
      }
      return token;
    },
  );
}

function replacementForSearchMatch(
  source: string,
  target: ProjectTextSearchMatch,
  options: ProjectTextSearchOptions,
  replacement: string,
): string {
  if (!options.regex) return replacement;
  const pattern = searchPattern(options);
  pattern.lastIndex = 0;
  for (let match = pattern.exec(source); match; match = pattern.exec(source)) {
    const text = match[0] ?? '';
    if (match.index === target.from && match.index + text.length === target.to) {
      return expandRegexReplacement(source, match, replacement);
    }
    if (text.length === 0) pattern.lastIndex += 1;
  }
  throw new Error(`Project search result became stale: ${target.file}:${target.line}:${target.column}`);
}

function extensionOf(path: string): string {
  const slash = path.lastIndexOf('/');
  const dot = path.lastIndexOf('.');
  return dot > slash ? path.slice(dot).toLowerCase() : '';
}

function dirname(path: string): string[] {
  const parts = path.split('/');
  parts.pop();
  return parts;
}

function relativeTarget(fromFile: ProjectFilePath, toFile: ProjectFilePath): string {
  const from = dirname(fromFile);
  const to = toFile.split('/');
  let common = 0;
  while (common < from.length && common < to.length && from[common] === to[common]) common += 1;
  const up = Array.from({ length: from.length - common }, () => '..');
  return [...up, ...to.slice(common)].join('/') || toFile;
}

function replaceListToken(body: string, oldValue: string, newValue: string): string {
  return body
    .split(',')
    .map((part) => {
      const leading = part.match(/^\s*/)?.[0] ?? '';
      const trailing = part.match(/\s*$/)?.[0] ?? '';
      return part.trim() === oldValue ? `${leading}${newValue}${trailing}` : part;
    })
    .join(',');
}

function uniqueCommandNodes(nodes: readonly SemanticNode[]): SemanticNode[] {
  const seen = new Set<string>();
  return nodes.filter((node) => {
    const key = `${node.file}:${node.range.from}:${node.range.to}`;
    if (seen.has(key)) return false;
    seen.add(key);
    return true;
  });
}


const CITATION_REVIEW_SIGNAL = /\b(?:show(?:s|ed)?|demonstrat(?:e|es|ed)|find(?:s|ing)?|indicat(?:e|es|ed)|suggest(?:s|ed)?|outperform(?:s|ed)?|improv(?:e|es|ed)|reduc(?:e|es|ed)|increas(?:e|es|ed)|significant(?:ly)?|correlat(?:e|es|ed|ion)|associated|caus(?:e|es|ed)|leads?\s+to)\b/i;

function semanticDiagnosticToCitationKind(kind: string): CitationAuditIssueKind | undefined {
  if (kind === 'missing-citation') return 'missing-entry';
  if (kind === 'unused-bibliography-entry') return 'unused-entry';
  if (kind === 'duplicate-citation-key') return 'duplicate-key';
  if (kind === 'duplicate-doi') return 'duplicate-doi';
  if (kind === 'duplicate-bibliography-work') return 'duplicate-work';
  if (kind === 'bibliography-title-mismatch') return 'title-mismatch';
  if (kind === 'bibliography-author-year-mismatch') return 'author-year-mismatch';
  if (kind === 'possible-renamed-citation-key') return 'possible-renamed-key';
  if (kind === 'malformed-bibliography') return 'malformed-entry';
  return undefined;
}

function bibliographyEntryLikelyHasDoi(entry: ProjectSemanticSnapshot['bibliographyEntries'][number]): boolean {
  const type = entry.type.toLowerCase();
  return ['article', 'inproceedings', 'conference', 'proceedings'].includes(type);
}

function blockHasCitation(block: VisualBlock): boolean {
  return block.kind === 'rich-paragraph' && block.inlines.some((inline) => inline.kind === 'citation');
}

export class ProjectToolsService extends BaseService {
  static deps = [
    'fileSystem',
    'latexSource',
    'navigation',
    'noteSnapshot',
    'projectCompile',
    'projectSemantic',
    'projectStorage',
    'projectCollaboration',
  ] as const;

  constructor(
    context: BaseServiceContext,
    private readonly dependencies: {
      fileSystem: FileSystemService;
      latexSource: LatexSourceService;
      navigation: NavigationService;
      noteSnapshot: NoteSnapshotService;
      projectCompile: ProjectCompileService;
      projectSemantic: ProjectSemanticService;
      projectStorage: ProjectStorageService;
      projectCollaboration: ProjectCollaborationService;
    },
  ) {
    super(SERVICE_NAME.projectToolsService, context, dependencies);
  }

  hookMount(): void {}

  async search(wsName: string, query: string): Promise<ProjectSemanticSearchResult[]> {
    const snapshot = await this.dependencies.projectSemantic.refresh(wsName);
    return searchProjectSemanticIndex(snapshot, query);
  }

  async resolveProjectSourceAnchor(
    wsName: string,
    anchor: SourceAnchor,
  ): Promise<{ file: ProjectFilePath; line: number; column: number; from: number; to: number }> {
    await this.dependencies.projectSemantic.refresh(wsName);
    const file = projectFilePath(anchor.filePath);
    const source = await this.sourceText(wsName, file);
    const revision = this.dependencies.latexSource.getSnapshot(WsPath.fromParts(wsName, file).wsPath)?.revision;
    const snapshot = this.dependencies.projectSemantic.getSnapshot(wsName);
    const resolution = resolveSourceAnchor(anchor, {
      source,
      sourceRevision: revision,
      semanticRange: (candidate) => {
        if (!candidate.semanticId || !snapshot) return undefined;
        const index = snapshot.files.get(file);
        const node = index?.nodes.find((item) => item.id === candidate.semanticId);
        return node?.range;
      },
    });
    if (resolution.status === 'outdated') throw new Error(resolution.reason);
    const location = lineColumnAt(source, resolution.value.from);
    return { file, ...location, from: resolution.value.from, to: resolution.value.to };
  }

  meaningStatus(wsName: string): Promise<ProjectMeaningStatus> {
    return this.dependencies.projectSemantic.meaningStatus(wsName);
  }

  prepareMeaningModel(
    onProgress?: (state: EmbeddingModelState) => void,
    signal?: AbortSignal,
  ): Promise<EmbeddingModelState> {
    return this.dependencies.projectSemantic.prepareMeaningModel(onProgress, signal);
  }

  removeMeaningModel(): Promise<void> {
    return this.dependencies.projectSemantic.removeMeaningModel();
  }

  clearMeaningIndex(wsName: string): Promise<void> {
    return this.dependencies.projectSemantic.clearMeaningIndex(wsName);
  }

  indexMeaning(
    wsName: string,
    options?: { signal?: AbortSignal; onProgress?: (progress: ProjectMeaningIndexProgress) => void },
  ) {
    return this.dependencies.projectSemantic.indexMeaning(wsName, options);
  }

  searchMeaning(
    wsName: string,
    query: string,
    options?: { limit?: number; signal?: AbortSignal; onProgress?: (progress: ProjectMeaningIndexProgress) => void },
  ): Promise<ProjectMeaningSearchResult[]> {
    return this.dependencies.projectSemantic.searchMeaning(wsName, query, options);
  }

  /** Hybrid deterministic + lexical + EmbeddingGemma retrieval for local project RAG. */
  async retrieveProjectEvidence(
    wsName: string,
    query: string,
    options: { limit?: number; signal?: AbortSignal } = {},
  ): Promise<ProjectRagRetrieval> {
    const normalized = query.trim();
    if (!normalized) return { query: '', evidence: [], semanticAvailable: false, warnings: [] };
    await this.flushAndRefresh(wsName);
    options.signal?.throwIfAborted();
    const snapshot = this.requireSnapshot(wsName);
    const candidates: ProjectRagCandidate[] = [];

    // Lexical ranking operates over the same scientific-aware passages used by
    // Meaning. This preserves exact scientific identifiers even without the
    // embedding model.
    for (const chunk of buildProjectMeaningChunks(snapshot)) {
      const lexicalScore = projectRagLexicalScore(normalized, `${chunk.title ?? ''} ${chunk.text}`);
      if (lexicalScore <= 0) continue;
      candidates.push({
        key: `lexical:${chunk.id}`,
        file: chunk.file,
        line: chunk.line,
        from: chunk.range.from,
        to: chunk.range.to,
        kind: chunk.kind,
        text: chunk.text,
        ...(chunk.title ? { title: chunk.title } : {}),
        lexicalScore,
      });
    }

    // Parser-backed symbol/metadata hits get a deterministic boost.
    for (const result of searchProjectSemanticIndex(snapshot, normalized).slice(0, 80)) {
      if (!result.range) continue;
      const source = await this.sourceText(wsName, result.file);
      const text = source.slice(result.range.from, result.range.to).trim();
      if (!text) continue;
      candidates.push({
        key: `semantic:${result.kind}:${result.file}:${result.range.from}:${result.range.to}`,
        file: result.file,
        line: result.line,
        from: result.range.from,
        to: result.range.to,
        kind: 'semantic-symbol',
        text,
        title: result.label,
        lexicalScore: projectRagLexicalScore(normalized, `${result.label} ${result.detail ?? ''} ${text}`),
        deterministicScore: 1,
      });
    }

    const warnings: string[] = [];
    const meaningStatus = await this.dependencies.projectSemantic.meaningStatus(wsName);
    const semanticAvailable =
      meaningStatus.capabilities.supported &&
      (meaningStatus.model.status === 'installed' || meaningStatus.model.status === 'ready');
    if (semanticAvailable) {
      try {
        const meaning = await this.dependencies.projectSemantic.searchMeaning(wsName, normalized, {
          limit: Math.max(12, (options.limit ?? 8) * 3),
          signal: options.signal,
        });
        for (const item of meaning) {
          candidates.push({
            key: `meaning:${item.chunk.id}`,
            file: item.chunk.file,
            line: item.chunk.line,
            from: item.chunk.range.from,
            to: item.chunk.range.to,
            kind: item.chunk.kind,
            text: item.chunk.text,
            ...(item.chunk.title ? { title: item.chunk.title } : {}),
            semanticScore: Math.max(0, Math.min(1, (item.score + 1) / 2)),
            lexicalScore: projectRagLexicalScore(normalized, `${item.chunk.title ?? ''} ${item.chunk.text}`),
          });
        }
      } catch (error) {
        if (options.signal?.aborted) throw error;
        warnings.push(`Meaning retrieval was unavailable: ${error instanceof Error ? error.message : String(error)}`);
      }
    } else {
      warnings.push('EmbeddingGemma is not installed; retrieval used deterministic and lexical project evidence only.');
    }

    const ranked = rankProjectRagCandidates(candidates, options.limit ?? 8);
    const evidence = await Promise.all(ranked.map(async (item) => {
      const source = await this.sourceText(wsName, item.file);
      const revision = this.dependencies.latexSource.getSnapshot(WsPath.fromParts(wsName, item.file).wsPath)?.revision;
      return {
        ...item,
        anchor: captureSourceAnchor({
          filePath: item.file,
          source,
          from: item.from,
          to: item.to,
          sourceRevision: revision,
          semanticKind: item.kind,
        }),
      };
    }));
    return { query: normalized, evidence, semanticAvailable, warnings };
  }


  async searchText(
    wsName: string,
    options: ProjectTextSearchOptions,
  ): Promise<ProjectTextSearchMatch[]> {
    await this.flushProjectSources(wsName);
    const provider = await this.dependencies.projectStorage.getProvider(wsName);
    const entries = await provider.listEntries();
    const scope = options.scope ?? { kind: 'project' as const };
    const excluded = options.excludeGlobs?.map((glob) => glob.trim()).filter(Boolean);
    const included = options.includeGlobs?.map((glob) => glob.trim()).filter(Boolean);
    const pattern = searchPattern(options);
    const maxResults = Math.max(1, Math.min(20_000, Math.trunc(options.maxResults ?? 5000)));
    const results: ProjectTextSearchMatch[] = [];

    for (const entry of entries) {
      if (
        entry.kind !== 'file' ||
        !isProjectReadableTextPath(entry.path) ||
        (entry.size !== undefined && entry.size > MAX_TEXT_SEARCH_FILE_BYTES)
      ) continue;
      const file = projectFilePath(entry.path);
      if (scope.kind === 'file' && file !== scope.file) continue;
      if (scope.kind === 'folder') {
        const folder = scope.folder.replace(/^\/+|\/+$/g, '');
        if (folder && file !== folder && !file.startsWith(`${folder}/`)) continue;
      }
      if (included?.length && !pathMatchesGlobs(file, included)) continue;
      if (excluded?.length && pathMatchesGlobs(file, excluded)) continue;

      const source = await this.sourceText(wsName, file);
      const loadedRevision = this.dependencies.latexSource.getSnapshot(
        WsPath.fromParts(wsName, file).wsPath,
      )?.revision;
      pattern.lastIndex = 0;
      for (let match = pattern.exec(source); match; match = pattern.exec(source)) {
        const text = match[0] ?? '';
        const from = match.index;
        const to = from + text.length;
        const location = lineColumnAt(source, from);
        const lineStart = source.lastIndexOf('\n', Math.max(0, from - 1)) + 1;
        const lineEndIndex = source.indexOf('\n', to);
        const lineEnd = lineEndIndex < 0 ? source.length : lineEndIndex;
        results.push({
          id: `${file}:${from}:${to}`,
          file,
          from,
          to,
          ...location,
          text,
          preview: source.slice(lineStart, lineEnd).trim(),
          anchor: captureSourceAnchor({
            filePath: file,
            source,
            from,
            to,
            sourceRevision: loadedRevision,
          }),
        });
        if (results.length >= maxResults) return results;
        if (text.length === 0) pattern.lastIndex += 1;
      }
    }
    return results;
  }

  async previewReplaceText(
    wsName: string,
    options: ProjectTextSearchOptions,
  ): Promise<ProjectTextReplacePreview> {
    const matches = await this.searchText(wsName, options);
    const counts = new Map<ProjectFilePath, number>();
    for (const match of matches) counts.set(match.file, (counts.get(match.file) ?? 0) + 1);
    return {
      matches,
      files: [...counts].map(([file, count]) => ({ file, matches: count })),
    };
  }

  async replaceText(
    wsName: string,
    options: ProjectTextSearchOptions,
    replacement: string,
    matchIds?: readonly string[],
  ): Promise<{ replaced: number; files: number }> {
    const matches = await this.searchText(wsName, options);
    const selected = matchIds === undefined
      ? matches
      : matches.filter((match) => matchIds.includes(match.id));
    if (!selected.length) return { replaced: 0, files: 0 };
    const byFile = new Map<ProjectFilePath, ProjectTextSearchMatch[]>();
    for (const match of selected) {
      const list = byFile.get(match.file) ?? [];
      list.push(match);
      byFile.set(match.file, list);
    }
    const edits = new Map<ProjectFilePath, SourceTextEdit[]>();
    for (const [file, fileMatches] of byFile) {
      const source = await this.sourceText(wsName, file);
      const fileEdits: SourceTextEdit[] = [];
      for (const match of fileMatches) {
        if (source.slice(match.from, match.to) !== match.text) {
          throw new Error(`Project search result became stale: ${file}:${match.line}:${match.column}`);
        }
        fileEdits.push({
          from: match.from,
          to: match.to,
          text: replacementForSearchMatch(source, match, options, replacement),
        });
      }
      edits.set(file, fileEdits);
    }
    await this.applyProjectEdits(wsName, edits);
    return { replaced: selected.length, files: byFile.size };
  }

  async doctor(wsName: string): Promise<ProjectDoctorReport> {
    const provider = await this.dependencies.projectStorage.getProvider(wsName);
    const entries = await provider.listEntries();
    const files = entries.filter((entry) => entry.kind === 'file');
    const selectedProfile = await this.dependencies.projectStorage.getProjectProfile(wsName);
    const detectedProfile = selectedProfile ?? (await this.dependencies.projectStorage.detectProjectTargets(wsName).catch(() => []))[0]?.profile;
    const mode = detectedProfile ? projectModeForProfile(detectedProfile) : undefined;
    const latexProject = mode?.id === 'latex'
      || detectedProfile?.projectType === 'latex'
      || detectedProfile?.providers.BUILD === 'latex'
      || detectedProfile?.providers.PREVIEW === 'latex';

    if (!latexProject) {
      const issues: ProjectDoctorIssue[] = [];
      if (!detectedProfile) {
        issues.push({ id: 'code:profile-missing', category: 'structure', severity: 'warning', message: 'No executable project runtime profile is configured or detected.' });
      } else {
        const entry = detectedProfile.entry ? projectFilePath(detectedProfile.entry) : undefined;
        if (entry && !files.some((file) => file.path === entry)) {
          issues.push({ id: 'code:entry-missing', category: 'structure', severity: 'error', message: `Configured entry file was not found: ${entry}` });
        }
        const providers = Object.values(detectedProfile.providers);
        const packageRuntime = providers.some((value) => ['node','typescript','vite','next','express'].includes(String(value)));
        if (packageRuntime && !files.some((file) => file.path === 'package.json')) {
          issues.push({ id: 'code:package-json-missing', category: 'structure', severity: 'warning', message: 'This runtime normally expects package.json, but the file is missing.' });
        }
        if (!Object.keys(detectedProfile.providers).length) {
          issues.push({ id: 'code:provider-missing', category: 'compilation', severity: 'warning', message: 'No run/build/test provider is configured for this project.' });
        }
      }
      if (files.length === 0) issues.push({ id: 'code:no-files', category: 'structure', severity: 'error', message: 'This project contains no files.' });
      const errors = issues.filter((issue) => issue.severity === 'error').length;
      const warnings = issues.filter((issue) => issue.severity === 'warning').length;
      return { score: Math.max(0, 100 - errors * 15 - warnings * 4), errors, warnings, issues };
    }

    const snapshot = await this.dependencies.projectSemantic.refresh(wsName);
    const semantic = createSemanticDoctorReport(snapshot);
    const issues: ProjectDoctorIssue[] = [...semantic.issues];

    if (latexProject) {
      const config = await this.dependencies.projectStorage.getProjectConfig(wsName);
      if (!files.some((entry) => entry.path === config.rootFile)) {
        issues.unshift({
          id: 'doctor:missing-root',
          category: 'structure',
          severity: 'error',
          message: `Root document was not found: ${config.rootFile}`,
          file: projectFilePath(config.rootFile),
        });
      }

      const referencedAssets = new Set<ProjectFilePath>();
      for (const asset of snapshot.assets) {
        if (!asset.target) continue;
        for (const file of files) {
          if (file.kind !== 'file' || !IMAGE_EXTENSION.test(file.path)) continue;
          if (assetReferenceMatchesFile(asset.file, asset.target, file.path)) referencedAssets.add(file.path);
        }
      }
      for (const file of files) {
        if (!IMAGE_EXTENSION.test(file.path) || referencedAssets.has(file.path)) continue;
        issues.push({
          id: `doctor:unused-asset:${file.path}`,
          category: 'assets',
          severity: 'info',
          message: `Image asset is not referenced by indexed LaTeX source: ${file.path}`,
          file: file.path,
        });
      }
    }

    if (latexProject) {
      const compileState = this.store.get(this.dependencies.projectCompile.$state);
      if (compileState.wsName === wsName && compileState.result) {
        compileState.result.diagnostics.forEach((diagnostic, index) => {
          issues.push({
            id: `compile:${index}:${diagnostic.code ?? ''}`,
            category: 'compilation',
            severity: diagnostic.severity,
            message: diagnostic.message,
            ...(diagnostic.file ? { file: diagnostic.file } : {}),
            ...(diagnostic.line ? { line: diagnostic.line } : {}),
          });
        });
      }
    }

    const errors = issues.filter((issue) => issue.severity === 'error').length;
    const warnings = issues.filter((issue) => issue.severity === 'warning').length;
    return {
      score: Math.max(0, 100 - errors * 15 - warnings * 4),
      errors,
      warnings,
      issues,
    };
  }

  async citationAudit(wsName: string): Promise<CitationAuditReport> {
    await this.flushAndRefresh(wsName);
    const snapshot = this.requireSnapshot(wsName);
    const issues: CitationAuditIssue[] = [];

    for (const [index, diagnostic] of snapshot.diagnostics.entries()) {
      const kind = semanticDiagnosticToCitationKind(diagnostic.kind);
      if (!kind) continue;
      let file = diagnostic.file;
      let line: number | undefined;
      let anchor: SourceAnchor | undefined;

      if (file) {
        const source = await this.sourceText(wsName, file);
        const node = snapshot.files.get(file)?.nodes.find((candidate) => candidate.target === diagnostic.target);
        if (node) {
          line = node.line;
          const revision = this.dependencies.latexSource.getSnapshot(WsPath.fromParts(wsName, file).wsPath)?.revision;
          anchor = captureSourceAnchor({
            filePath: file,
            source,
            from: node.range.from,
            to: node.range.to,
            sourceRevision: revision,
            semanticId: node.id,
            semanticKind: node.kind,
          });
        }
      } else if (diagnostic.target) {
        const entry = snapshot.bibliographyEntries.find((candidate) => {
          if (candidate.key === diagnostic.target) return true;
          const doi = getBibField(candidate, 'doi')?.value
            .replace(/^https?:\/\/(?:dx\.)?doi\.org\//i, '')
            .replace(/^doi:\s*/i, '')
            .trim()
            .toLowerCase();
          return doi === diagnostic.target;
        });
        if (entry) {
          file = entry.file;
          line = entry.line;
          const source = await this.sourceText(wsName, entry.file);
          const revision = this.dependencies.latexSource.getSnapshot(WsPath.fromParts(wsName, entry.file).wsPath)?.revision;
          anchor = captureSourceAnchor({
            filePath: entry.file,
            source,
            from: entry.range.from,
            to: entry.range.to,
            sourceRevision: revision,
            semanticId: entry.id,
            semanticKind: 'bibliography',
          });
        }
      }

      issues.push({
        id: `citation-audit:${diagnostic.kind}:${index}:${diagnostic.file ?? ''}:${diagnostic.target ?? ''}`,
        kind,
        severity: diagnostic.severity,
        message: diagnostic.message,
        ...(file ? { file } : {}),
        ...(line ? { line } : {}),
        ...(diagnostic.target ? { target: diagnostic.target } : {}),
        ...(anchor ? { anchor } : {}),
      });
    }

    for (const entry of snapshot.bibliographyEntries) {
      const missing = [
        !getBibField(entry, 'title')?.value.trim() ? 'title' : undefined,
        !getBibField(entry, 'author')?.value.trim() ? 'author' : undefined,
        !getBibField(entry, 'year')?.value.trim() ? 'year' : undefined,
      ].filter((value): value is string => Boolean(value));
      if (!missing.length) continue;
      const source = await this.sourceText(wsName, entry.file);
      const revision = this.dependencies.latexSource.getSnapshot(WsPath.fromParts(wsName, entry.file).wsPath)?.revision;
      issues.push({
        id: `citation-audit:incomplete-metadata:${entry.file}:${entry.key}`,
        kind: 'incomplete-metadata',
        severity: 'info',
        message: `Bibliography entry ${entry.key} is missing ${missing.join(', ')} metadata.`,
        file: entry.file,
        line: entry.line,
        target: entry.key,
        anchor: captureSourceAnchor({
          filePath: entry.file,
          source,
          from: entry.range.from,
          to: entry.range.to,
          sourceRevision: revision,
          semanticId: entry.id,
          semanticKind: 'bibliography',
        }),
      });
    }

    const doiCandidates: CitationAuditDoiCandidate[] = [];
    for (const entry of snapshot.bibliographyEntries) {
      if (!bibliographyEntryLikelyHasDoi(entry) || getBibField(entry, 'doi')?.value.trim()) continue;
      const title = getBibField(entry, 'title')?.value.trim();
      if (!title) continue;
      const author = getBibField(entry, 'author')?.value.trim() || undefined;
      const year = Number(getBibField(entry, 'year')?.value) || undefined;
      doiCandidates.push({ file: entry.file, key: entry.key, title, ...(author ? { author } : {}), ...(year ? { year } : {}) });
    }

    return {
      issues,
      errors: issues.filter((issue) => issue.severity === 'error').length,
      warnings: issues.filter((issue) => issue.severity === 'warning').length,
      doiCandidates,
    };
  }

  async researchReview(wsName: string): Promise<ProjectResearchReviewReport> {
    await this.flushAndRefresh(wsName);
    const snapshot = this.requireSnapshot(wsName);
    const findings: ProjectResearchReviewFinding[] = [];
    let filesReviewed = 0;

    for (const [file, fileIndex] of snapshot.files) {
      if (!TEX_EXTENSION.test(file)) continue;
      filesReviewed += 1;
      const source = await this.sourceText(wsName, file);
      const revision = this.dependencies.latexSource.getSnapshot(WsPath.fromParts(wsName, file).wsPath)?.revision;
      const addFinding = (
        kind: ProjectResearchReviewFindingKind,
        severity: 'warning' | 'info',
        title: string,
        message: string,
        from: number,
        to: number,
        semanticId?: string,
        semanticKind?: string,
      ) => {
        const location = lineColumnAt(source, from);
        findings.push({
          id: `research:${kind}:${file}:${from}:${to}`,
          kind,
          severity,
          title,
          message,
          file,
          line: location.line,
          anchor: captureSourceAnchor({
            filePath: file,
            source,
            from,
            to,
            sourceRevision: revision,
            ...(semanticId ? { semanticId } : {}),
            ...(semanticKind ? { semanticKind } : {}),
          }),
        });
      };

      for (const block of fileIndex.visualBlocks) {
        if ((block.kind === 'paragraph' || block.kind === 'rich-paragraph') && block.text.trim().length >= 140) {
          if (!blockHasCitation(block) && CITATION_REVIEW_SIGNAL.test(block.text)) {
            addFinding(
              'citation-coverage',
              'info',
              'Potential citation gap',
              'This claim-like paragraph has no indexed citation. Review whether it needs supporting or contrasting literature; Learnitive has not asserted that a citation is required.',
              block.range.from,
              block.range.to,
              block.id,
              block.kind,
            );
          }
        }
        if (block.kind === 'figure') {
          if (!block.caption?.trim()) addFinding('figure-caption', 'info', 'Figure has no caption', 'Review whether this figure needs a descriptive caption for the target venue.', block.range.from, block.range.to, block.id, 'figure');
          if (!block.label?.trim()) addFinding('figure-label', 'info', 'Figure has no label', 'A label can make cross-references more reliable if this figure is referenced from the manuscript.', block.range.from, block.range.to, block.id, 'figure');
          if (!/\\Description\s*\{/.test(block.text)) addFinding('figure-accessibility', 'info', 'Review figure accessibility text', 'No existing \\Description command was indexed for this figure. Add accessibility text only when supported by the document class or venue template.', block.range.from, block.range.to, block.id, 'figure');
        }
        if (block.kind === 'table') {
          if (!block.caption?.trim()) addFinding('table-caption', 'info', 'Table has no caption', 'Review whether this table needs a descriptive caption for the target venue.', block.range.from, block.range.to, block.id, 'table');
          if (!block.label?.trim()) addFinding('table-label', 'info', 'Table has no label', 'A label can make cross-references more reliable if this table is referenced from the manuscript.', block.range.from, block.range.to, block.id, 'table');
        }
      }
    }

    for (const diagnostic of snapshot.diagnostics) {
      if (!diagnostic.file || !diagnostic.target) continue;
      if (!['missing-reference', 'missing-citation', 'duplicate-doi', 'duplicate-bibliography-work', 'bibliography-title-mismatch', 'bibliography-author-year-mismatch'].includes(diagnostic.kind)) continue;
      const file = diagnostic.file;
      const source = await this.sourceText(wsName, file);
      const node = snapshot.files.get(file)?.nodes.find((candidate) => candidate.target === diagnostic.target);
      if (!node) continue;
      const revision = this.dependencies.latexSource.getSnapshot(WsPath.fromParts(wsName, file).wsPath)?.revision;
      findings.push({
        id: `research-integrity:${diagnostic.kind}:${file}:${node.range.from}`,
        kind: diagnostic.kind === 'missing-reference' ? 'reference-integrity' : 'bibliography-integrity',
        severity: diagnostic.severity === 'error' ? 'warning' : diagnostic.severity,
        title: diagnostic.kind === 'missing-reference' ? 'Reference integrity issue' : 'Bibliography integrity issue',
        message: diagnostic.message,
        file,
        line: node.line,
        anchor: captureSourceAnchor({ filePath: file, source, from: node.range.from, to: node.range.to, sourceRevision: revision, semanticId: node.id, semanticKind: node.kind }),
      });
    }

    return { findings, filesReviewed };
  }

  async renameLabel(wsName: string, oldLabel: string, newLabel: string): Promise<void> {
    this.assertSymbol(newLabel, 'label');
    await this.flushAndRefresh(wsName);
    const snapshot = this.requireSnapshot(wsName);
    if (snapshot.labels.some((node) => node.target === newLabel)) {
      throw new Error(`A label already exists with the name ${newLabel}`);
    }
    const nodes = [
      ...snapshot.labels.filter((node) => node.target === oldLabel),
      ...snapshot.references.filter((node) => node.target === oldLabel),
    ];
    if (nodes.length === 0) throw new Error(`Label not found: ${oldLabel}`);
    await this.applyNodeBodyReplacements(wsName, nodes, oldLabel, newLabel);
  }

  async renameCitationKey(wsName: string, oldKey: string, newKey: string): Promise<void> {
    this.assertSymbol(newKey, 'citation key');
    await this.flushAndRefresh(wsName);
    const snapshot = this.requireSnapshot(wsName);
    if (snapshot.bibliographyEntries.some((entry) => entry.key === newKey)) {
      throw new Error(`A bibliography entry already uses the key ${newKey}`);
    }
    const matchingEntries = snapshot.bibliographyEntries.filter((entry) => entry.key === oldKey);
    if (matchingEntries.length !== 1) {
      throw new Error(matchingEntries.length === 0 ? `Citation key not found: ${oldKey}` : `Citation key is duplicated and cannot be safely renamed: ${oldKey}`);
    }

    const edits = new Map<ProjectFilePath, SourceTextEdit[]>();
    const entry = matchingEntries[0]!;
    this.pushEdit(edits, entry.file, { ...entry.keyRange, text: newKey });
    for (const citation of uniqueCommandNodes(snapshot.citations.filter((node) => node.target === oldKey))) {
      if (!citation.bodyRange) continue;
      const source = await this.sourceText(wsName, citation.file);
      const body = source.slice(citation.bodyRange.from, citation.bodyRange.to);
      this.pushEdit(edits, citation.file, {
        ...citation.bodyRange,
        text: replaceListToken(body, oldKey, newKey),
      });
    }
    await this.applyProjectEdits(wsName, edits);
  }

  async moveProjectFile(wsName: string, from: ProjectFilePath, to: ProjectFilePath): Promise<void> {
    if (from === to) return;
    this.dependencies.projectCollaboration.assertDisruptiveGitAllowed(wsName, 'move or rename project files');
    const semanticFile = TEX_EXTENSION.test(from) || BIB_EXTENSION.test(from) || IMAGE_EXTENSION.test(from);
    if (!semanticFile) {
      const provider = await this.dependencies.projectStorage.getProvider(wsName);
      const entries = await provider.listEntries();
      if (!entries.some((entry) => entry.kind === 'file' && entry.path === from)) throw new Error(`Project file not found: ${from}`);
      if (entries.some((entry) => entry.kind === 'file' && entry.path === to)) throw new Error(`Destination already exists: ${to}`);
      await this.dependencies.fileSystem.renameFile({ oldWsPath: WsPath.fromParts(wsName, from).wsPath, newWsPath: WsPath.fromParts(wsName, to).wsPath });
      const profile = await this.dependencies.projectStorage.getProjectProfile(wsName);
      if (profile?.entry === from) await this.dependencies.projectStorage.updateProjectProfile(wsName, { ...profile, entry: to });
      const active = this.dependencies.navigation.resolveAtoms().activeWsFilePath;
      if (active?.wsPath === WsPath.fromParts(wsName, from).wsPath) this.dependencies.navigation.goWsFile(WsPath.fromParts(wsName, to).wsPath);
      this.dependencies.fileSystem.refreshFileTree();
      return;
    }
    if (TEX_EXTENSION.test(from) !== TEX_EXTENSION.test(to) || BIB_EXTENSION.test(from) !== BIB_EXTENSION.test(to)) {
      throw new Error(`Safe project refactoring cannot change the semantic file type: ${from} → ${to}`);
    }
    if (IMAGE_EXTENSION.test(from) && !IMAGE_EXTENSION.test(to)) {
      throw new Error(`Image assets must keep a supported image extension: ${to}`);
    }
    if (extensionOf(from) !== extensionOf(to)) {
      throw new Error(`Safe project refactoring renames files but does not convert formats: ${from} → ${to}`);
    }
    await this.flushAndRefresh(wsName);
    const snapshot = this.requireSnapshot(wsName);
    const [provider, projectConfig] = await Promise.all([
      this.dependencies.projectStorage.getProvider(wsName),
      this.dependencies.projectStorage.getProjectConfig(wsName),
    ]);
    const entries = await provider.listEntries();
    if (!entries.some((entry) => entry.kind === 'file' && entry.path === from)) throw new Error(`Project file not found: ${from}`);
    if (entries.some((entry) => entry.kind === 'file' && entry.path === to)) throw new Error(`Destination already exists: ${to}`);

    const edits = new Map<ProjectFilePath, SourceTextEdit[]>();
    if (TEX_EXTENSION.test(from)) {
      // Rewrite callers that reference the moved source file.
      for (const node of snapshot.inputs) {
        if (!node.target || !node.bodyRange || resolveInputTarget(node.file, node.target) !== from) continue;
        let target = relativeTarget(node.file === from ? to : node.file, to);
        if (!/\.[A-Za-z0-9]+$/.test(node.target)) target = target.replace(/\.(?:tex|ltx)$/i, '');
        this.pushEdit(edits, node.file, { ...node.bodyRange, text: target });
      }

      // Moving a TeX file also changes the base directory used by relative
      // paths *inside* that file. Preserve what those paths point at after
      // the move instead of silently breaking inputs, graphics or .bib files.
      const movedFileIndex = snapshot.files.get(from);
      if (movedFileIndex) {
        for (const node of movedFileIndex.nodes) {
          if (!node.target || !node.bodyRange) continue;
          if (node.kind === 'input' || node.kind === 'include') {
            const resolved = resolveInputTarget(from, node.target);
            if (!resolved) continue;
            const destination = resolved === from ? to : resolved;
            let target = relativeTarget(to, destination);
            if (!/\.[A-Za-z0-9]+$/.test(node.target)) target = target.replace(/\.(?:tex|ltx)$/i, '');
            this.pushEdit(edits, from, { ...node.bodyRange, text: target });
          } else if (node.kind === 'asset') {
            const resolved = resolveAssetTarget(from, node.target);
            if (!resolved) continue;
            let target = relativeTarget(to, resolved);
            if (!/\.[A-Za-z0-9]+$/.test(node.target)) target = target.replace(/\.[A-Za-z0-9]+$/, '');
            this.pushEdit(edits, from, { ...node.bodyRange, text: target });
          }
        }

        const bibliographyNodes = movedFileIndex.nodes.filter(
          (node) => node.kind === 'bibliography' && node.target && node.bodyRange,
        );
        const bibliographyRanges = new Map<string, typeof bibliographyNodes>();
        for (const node of bibliographyNodes) {
          const key = `${node.bodyRange!.from}:${node.bodyRange!.to}`;
          const list = bibliographyRanges.get(key) ?? [];
          list.push(node);
          bibliographyRanges.set(key, list);
        }
        const source = await this.sourceText(wsName, from);
        for (const nodes of bibliographyRanges.values()) {
          const range = nodes[0]!.bodyRange!;
          let body = source.slice(range.from, range.to);
          for (const node of nodes) {
            const resolved = resolveBibliographyTarget(from, node.target!);
            if (!resolved) continue;
            let target = relativeTarget(to, resolved);
            if (!node.target!.toLowerCase().endsWith('.bib')) target = target.replace(/\.bib$/i, '');
            body = replaceListToken(body, node.target!, target);
          }
          this.pushEdit(edits, from, { ...range, text: body });
        }
      }
    } else if (BIB_EXTENSION.test(from)) {
      for (const file of snapshot.files.values()) {
        for (const node of file.nodes) {
          if (node.kind !== 'bibliography' || !node.target || !node.bodyRange) continue;
          if (resolveBibliographyTarget(node.file, node.target) !== from) continue;
          let target = relativeTarget(node.file, to);
          if (!node.target.toLowerCase().endsWith('.bib')) target = target.replace(/\.bib$/i, '');
          const source = await this.sourceText(wsName, node.file);
          const body = source.slice(node.bodyRange.from, node.bodyRange.to);
          this.pushEdit(edits, node.file, {
            ...node.bodyRange,
            text: replaceListToken(body, node.target, target),
          });
        }
      }
    } else if (IMAGE_EXTENSION.test(from)) {
      for (const node of snapshot.assets) {
        if (!node.target || !node.bodyRange || !assetReferenceMatchesFile(node.file, node.target, from)) continue;
        let target = relativeTarget(node.file, to);
        if (!/\.[A-Za-z0-9]+$/.test(node.target)) target = target.replace(/\.[A-Za-z0-9]+$/, '');
        this.pushEdit(edits, node.file, { ...node.bodyRange, text: target });
      }
    }

    const originals = await this.prepareProjectEdits(wsName, edits);
    let moved = false;
    let configUpdated = false;
    try {
      await this.applyPreparedEdits(wsName, originals, edits);
      await this.dependencies.fileSystem.renameFile({
        oldWsPath: WsPath.fromParts(wsName, from).wsPath,
        newWsPath: WsPath.fromParts(wsName, to).wsPath,
      });
      moved = true;
      if (projectConfig.rootFile === from) {
        await this.dependencies.projectStorage.updateProjectConfig(wsName, (current) => ({
          ...current,
          rootFile: to,
        }));
        configUpdated = true;
      }
      const oldWsPath = WsPath.fromParts(wsName, from).wsPath;
      const newWsPath = WsPath.fromParts(wsName, to).wsPath;
      this.dependencies.latexSource.retargetLoadedDocument(oldWsPath, newWsPath);
      const active = this.dependencies.navigation.resolveAtoms().activeWsFilePath;
      if (active?.wsPath === oldWsPath) this.dependencies.navigation.goWsFile(newWsPath);
      this.dependencies.fileSystem.refreshFileTree();
      await this.dependencies.projectSemantic.refresh(wsName);
    } catch (error) {
      if (configUpdated) {
        try {
          await this.dependencies.projectStorage.updateProjectConfig(wsName, (current) => ({
            ...current,
            rootFile: from,
          }));
        } catch { /* preserve primary error */ }
      }
      if (moved) {
        const oldWsPath = WsPath.fromParts(wsName, from).wsPath;
        const newWsPath = WsPath.fromParts(wsName, to).wsPath;
        try {
          await this.dependencies.fileSystem.renameFile({
            oldWsPath: newWsPath,
            newWsPath: oldWsPath,
          });
          this.dependencies.latexSource.retargetLoadedDocument(newWsPath, oldWsPath);
          const active = this.dependencies.navigation.resolveAtoms().activeWsFilePath;
          if (active?.wsPath === newWsPath) this.dependencies.navigation.goWsFile(oldWsPath);
          this.dependencies.fileSystem.refreshFileTree();
        } catch { /* preserve primary error */ }
      }
      await this.rollbackPreparedEdits(wsName, originals);
      throw error;
    }
  }

  private assertSymbol(value: string, kind: string): void {
    if (!SAFE_SYMBOL.test(value)) throw new Error(`Invalid ${kind}: ${value}`);
  }

  private requireSnapshot(wsName: string): ProjectSemanticSnapshot {
    const snapshot = this.dependencies.projectSemantic.getSnapshot(wsName);
    if (!snapshot) throw new Error('Project semantic index is unavailable');
    return snapshot;
  }

  private async flushAndRefresh(wsName: string): Promise<void> {
    await this.flushProjectSources(wsName);
    await this.dependencies.projectSemantic.refresh(wsName);
  }

  private async flushProjectSources(wsName: string): Promise<void> {
    await this.dependencies.projectCollaboration.flushToStorage(wsName);
    await this.dependencies.latexSource.flushWorkspace(wsName);
    if (this.dependencies.latexSource.hasPendingOrFailedSaveInWorkspace(wsName)) {
      throw new Error('Project operation blocked because a source file could not be saved safely');
    }
  }

  private async sourceText(wsName: string, file: ProjectFilePath): Promise<string> {
    const wsPath = WsPath.fromParts(wsName, file).wsPath;
    const snapshot = this.dependencies.latexSource.getSnapshot(wsPath);
    if (snapshot) return snapshot.text;

    // Project-wide refactors must also work on files that have never been
    // opened in an editor. ProjectStorageService is the storage-neutral
    // project boundary, while LatexSourceService remains authoritative for
    // any document that is already loaded/open.
    const provider = await this.dependencies.projectStorage.getProvider(wsName);
    const bytes = await provider.readFile(file);
    if (!bytes) throw new Error(`Project source file not found: ${file}`);
    return SOURCE_DECODER.decode(bytes);
  }

  private pushEdit(map: Map<ProjectFilePath, SourceTextEdit[]>, file: ProjectFilePath, edit: SourceTextEdit): void {
    const list = map.get(file) ?? [];
    const existing = list.find((candidate) => candidate.from === edit.from && candidate.to === edit.to);
    if (existing) {
      if (existing.text !== edit.text) {
        throw new Error(`Conflicting refactor edits for ${file}:${edit.from}-${edit.to}`);
      }
      return;
    }
    list.push(edit);
    map.set(file, list);
  }

  private async applyNodeBodyReplacements(
    wsName: string,
    nodes: readonly SemanticNode[],
    expected: string,
    replacement: string,
  ): Promise<void> {
    const edits = new Map<ProjectFilePath, SourceTextEdit[]>();
    for (const node of nodes) {
      if (!node.bodyRange) continue;
      const source = await this.sourceText(wsName, node.file);
      if (source.slice(node.bodyRange.from, node.bodyRange.to).trim() !== expected) {
        throw new Error(`Source changed while preparing project refactor: ${node.file}:${node.line}`);
      }
      this.pushEdit(edits, node.file, { ...node.bodyRange, text: replacement });
    }
    await this.applyProjectEdits(wsName, edits);
  }

  private async prepareProjectEdits(
    wsName: string,
    edits: ReadonlyMap<ProjectFilePath, readonly SourceTextEdit[]>,
  ): Promise<Map<ProjectFilePath, string>> {
    const originals = new Map<ProjectFilePath, string>();
    for (const file of edits.keys()) {
      const source = await this.sourceText(wsName, file);
      originals.set(file, source);
      await this.dependencies.noteSnapshot.captureCheckpoint(
        WsPath.fromParts(wsName, file).wsPath,
        source,
      );
    }
    return originals;
  }

  private async applyProjectEdits(
    wsName: string,
    edits: ReadonlyMap<ProjectFilePath, readonly SourceTextEdit[]>,
  ): Promise<void> {
    const originals = await this.prepareProjectEdits(wsName, edits);
    try {
      await this.applyPreparedEdits(wsName, originals, edits);
      await this.dependencies.projectSemantic.refresh(wsName);
    } catch (error) {
      await this.rollbackPreparedEdits(wsName, originals);
      throw error;
    }
  }

  private async applyPreparedEdits(
    wsName: string,
    originals: ReadonlyMap<ProjectFilePath, string>,
    edits: ReadonlyMap<ProjectFilePath, readonly SourceTextEdit[]>,
  ): Promise<void> {
    for (const [file, fileEdits] of edits) {
      const original = originals.get(file);
      if (original === undefined) throw new Error(`Missing refactor source snapshot: ${file}`);

      const wsPath = WsPath.fromParts(wsName, file).wsPath;
      if (this.dependencies.projectCollaboration.isSourcePathCollaborative(wsName, file)) {
        this.dependencies.projectCollaboration.applyTextEdits(wsName, file, fileEdits, { reviewSource: 'toolbar', semanticKind: 'project-tool-refactor' });
      } else if (this.dependencies.latexSource.getSnapshot(wsPath)) {
        this.dependencies.latexSource.applyEdits(wsPath, fileEdits, 'refactor');
      } else {
        // Unopened project files have no LatexSourceService session. Apply the
        // same exact edits directly at the project-storage boundary.
        const next = applySourceTextEdits(original, fileEdits);
        const fileWsPath = WsPath.fromParts(wsName, file).wsPath;
        await this.dependencies.fileSystem.writeFile(
          fileWsPath,
          new File([next], file.split('/').at(-1) ?? 'source.tex', { type: 'text/plain;charset=utf-8' }),
        );
      }
    }
    if (this.dependencies.projectCollaboration.isActive(wsName)) await this.dependencies.projectCollaboration.flushToStorage(wsName);
    else {
      await this.dependencies.latexSource.flushWorkspace(wsName);
      if (this.dependencies.latexSource.hasPendingOrFailedSaveInWorkspace(wsName)) throw new Error('Project refactor could not be saved safely');
    }
  }

  private async rollbackPreparedEdits(
    wsName: string,
    originals: ReadonlyMap<ProjectFilePath, string>,
  ): Promise<void> {
    for (const [file, source] of originals) {
      const wsPath = WsPath.fromParts(wsName, file).wsPath;
      const snapshot = this.dependencies.latexSource.getSnapshot(wsPath);
      if (this.dependencies.projectCollaboration.isSourcePathCollaborative(wsName, file)) {
        this.dependencies.projectCollaboration.replaceCollaborativeText(wsName, file, source, { reviewSource: 'system', trackReview: false });
      } else if (snapshot) {
        this.dependencies.latexSource.update(wsPath, source, 'refactor');
      } else {
        const fileWsPath = WsPath.fromParts(wsName, file).wsPath;
        await this.dependencies.fileSystem.writeFile(
          fileWsPath,
          new File([source], file.split('/').at(-1) ?? 'source.tex', { type: 'text/plain;charset=utf-8' }),
        );
      }
    }
    if (this.dependencies.projectCollaboration.isActive(wsName)) await this.dependencies.projectCollaboration.flushToStorage(wsName);
    else await this.dependencies.latexSource.flushWorkspace(wsName);
    await this.dependencies.projectSemantic.refresh(wsName).catch(() => undefined);
  }
}
