import { computeDiffContextHash } from '@shared/utils/diffContextHash';
import { createLogger } from '@shared/utils/logger';
import { isWindowsishPath, normalizePathForComparison } from '@shared/utils/platformPath';
import { createHash } from 'crypto';
import { applyPatch, structuredPatch } from 'diff';
import { mkdir, readFile, unlink, writeFile } from 'fs/promises';
import { diff3Merge } from 'node-diff3';
import { dirname } from 'path';

import { HunkSnippetMatcher } from './HunkSnippetMatcher';

import type {
  ApplyReviewRequest,
  ApplyReviewResult,
  ConflictCheckResult,
  FileChangeWithContent,
  LedgerChangeRelation,
  RejectResult,
  SnippetDiff,
} from '@shared/types';
import type { StructuredPatchHunk } from 'diff';

const logger = createLogger('Service:ReviewApplierService');

type ApplyErrorCode = NonNullable<ApplyReviewResult['errors'][number]['code']>;
type LedgerApplyOutcome =
  | { handled: false }
  | { handled: true; status: 'applied' | 'skipped' }
  | { handled: true; status: 'conflict' | 'error'; error: string; code: ApplyErrorCode };

type CurrentTextReadResult =
  | { missing: true; content: '' }
  | { missing: false; content: string }
  | { missing: false; content: ''; error: string };

function getCurrentTextReadError(result: CurrentTextReadResult): string | null {
  return 'error' in result ? result.error : null;
}

/**
 * Service for applying reject decisions from code review.
 *
 * Supports:
 * - Conflict detection (file changed since review was computed)
 * - Hunk-level rejection (reverse specific hunks)
 * - File-level rejection (restore entire file to original)
 * - Preview mode (show what would change without writing)
 * - Batch review application
 */
export class ReviewApplierService {
  private readonly matcher = new HunkSnippetMatcher();

  /**
   * Check if the file on disk has been modified since the review was computed.
   * Compares current disk content against the expected modified content.
   */
  async checkConflict(filePath: string, expectedModified: string): Promise<ConflictCheckResult> {
    let currentContent: string;
    try {
      currentContent = await readFile(filePath, 'utf8');
    } catch {
      return {
        hasConflict: true,
        conflictContent: null,
        currentContent: '',
        originalContent: expectedModified,
      };
    }

    const hasConflict = currentContent !== expectedModified;

    return {
      hasConflict,
      conflictContent: hasConflict ? currentContent : null,
      currentContent,
      originalContent: expectedModified,
    };
  }

  /**
   * Reject specific hunks from a file's changes.
   *
   * PRIMARY approach: snippet-level replacement with positional reverse.
   * FALLBACK: hunk-level inverse patch when snippet replacement fails.
   */
  async rejectHunks(
    _teamName: string,
    filePath: string,
    original: string,
    modified: string,
    hunkIndices: number[],
    snippets: SnippetDiff[]
  ): Promise<RejectResult> {
    // Try snippet-level reverse first (most accurate)
    const snippetResult = this.trySnippetLevelReject(original, modified, hunkIndices, snippets);
    if (snippetResult) {
      try {
        await writeFile(filePath, snippetResult.newContent, 'utf8');
        return snippetResult;
      } catch (err) {
        return {
          success: false,
          newContent: modified,
          hadConflicts: false,
          conflictDescription: `Не удалось записать файл: ${String(err)}`,
        };
      }
    }

    // Fallback: hunk-level inverse patch
    const patchResult = this.tryHunkLevelReject(original, modified, hunkIndices);
    if (patchResult) {
      try {
        await writeFile(filePath, patchResult.newContent, 'utf8');
        return patchResult;
      } catch (err) {
        return {
          success: false,
          newContent: modified,
          hadConflicts: false,
          conflictDescription: `Не удалось записать файл: ${String(err)}`,
        };
      }
    }

    // Both approaches failed — try three-way merge as last resort
    const mergeResult = threeWayMerge(original, modified, original);
    if (!mergeResult.hasConflicts) {
      try {
        await writeFile(filePath, mergeResult.content, 'utf8');
        return {
          success: true,
          newContent: mergeResult.content,
          hadConflicts: false,
        };
      } catch (err) {
        return {
          success: false,
          newContent: modified,
          hadConflicts: false,
          conflictDescription: `Не удалось записать файл: ${String(err)}`,
        };
      }
    }

    return {
      success: false,
      newContent: modified,
      hadConflicts: true,
      conflictDescription: 'Не удалось применить reject: все стратегии завершились неудачно',
    };
  }

  /**
   * Reject the entire file — restore to original content.
   */
  async rejectFile(
    _teamName: string,
    filePath: string,
    original: string,
    modified: string
  ): Promise<RejectResult> {
    // Check for conflicts first
    const conflict = await this.checkConflict(filePath, modified);
    if (conflict.hasConflict) {
      // File was modified since review — try three-way merge
      const currentContent = conflict.currentContent;
      const mergeResult = threeWayMerge(modified, currentContent, original);

      if (mergeResult.hasConflicts) {
        return {
          success: false,
          newContent: currentContent,
          hadConflicts: true,
          conflictDescription:
            'Файл был изменён после вычисления review, и три-сторонний merge обнаружил конфликты',
        };
      }

      try {
        await writeFile(filePath, mergeResult.content, 'utf8');
        return {
          success: true,
          newContent: mergeResult.content,
          hadConflicts: false,
        };
      } catch (err) {
        return {
          success: false,
          newContent: currentContent,
          hadConflicts: false,
          conflictDescription: `Не удалось записать файл: ${String(err)}`,
        };
      }
    }

    // No conflict — simply write original content
    try {
      await writeFile(filePath, original, 'utf8');
      return {
        success: true,
        newContent: original,
        hadConflicts: false,
      };
    } catch (err) {
      return {
        success: false,
        newContent: modified,
        hadConflicts: false,
        conflictDescription: `Не удалось записать файл: ${String(err)}`,
      };
    }
  }

  /**
   * Preview what a reject operation would produce WITHOUT writing to disk.
   */
  async previewReject(
    _filePath: string,
    original: string,
    modified: string,
    hunkIndices: number[],
    snippets: SnippetDiff[]
  ): Promise<{ preview: string; hasConflicts: boolean }> {
    // Try snippet-level reverse
    const snippetResult = this.trySnippetLevelReject(original, modified, hunkIndices, snippets);
    if (snippetResult) {
      return { preview: snippetResult.newContent, hasConflicts: false };
    }

    // Fallback: hunk-level inverse patch
    const patchResult = this.tryHunkLevelReject(original, modified, hunkIndices);
    if (patchResult) {
      return { preview: patchResult.newContent, hasConflicts: patchResult.hadConflicts };
    }

    // Final fallback — three-way merge
    const mergeResult = threeWayMerge(original, modified, original);
    return { preview: mergeResult.content, hasConflicts: mergeResult.hasConflicts };
  }

  /**
   * Apply all review decisions in batch.
   */
  async applyReviewDecisions(
    request: ApplyReviewRequest,
    fileContents = new Map<string, FileChangeWithContent>()
  ): Promise<ApplyReviewResult> {
    let applied = 0;
    let skipped = 0;
    let conflicts = 0;
    const errors: ApplyReviewResult['errors'] = [];

    for (const decision of request.decisions) {
      const fileContent = fileContents.get(decision.filePath);
      if (!fileContent) {
        skipped++;
        continue;
      }

      // Skip files where all hunks are accepted (nothing to reject)
      if (decision.fileDecision === 'accepted') {
        skipped++;
        continue;
      }

      const original = fileContent.originalFullContent;
      const modified = fileContent.modifiedFullContent;

      const rejectedHunkIndices = Object.entries(decision.hunkDecisions)
        .filter(([, d]) => d === 'rejected')
        .map(([idx]) => parseInt(idx, 10));

      const allHunksRejected =
        Object.keys(decision.hunkDecisions).length > 0 &&
        Object.values(decision.hunkDecisions).every((d) => d === 'rejected');
      const hasNewFileSnippet = fileContent.snippets.some(
        (s) => s.type === 'write-new' || s.ledger?.operation === 'create'
      );

      // Special case: rejecting an entirely new file should remove it from disk.
      // IMPORTANT: Do NOT delete on partial reject — users may want to keep parts of the new file.
      const shouldDeleteNewFile =
        fileContent.isNewFile &&
        hasNewFileSnippet &&
        original === '' &&
        (decision.fileDecision === 'rejected' || allHunksRejected);

      const ledgerOutcome = await this.tryApplyLedgerDecision(
        decision.filePath,
        original,
        modified,
        decision.fileDecision === 'rejected',
        allHunksRejected,
        rejectedHunkIndices,
        decision.hunkContextHashes,
        fileContent.snippets
      );
      if (ledgerOutcome.handled) {
        if (ledgerOutcome.status === 'applied') {
          applied++;
        } else if (ledgerOutcome.status === 'skipped') {
          skipped++;
        } else if (ledgerOutcome.status === 'conflict' || ledgerOutcome.status === 'error') {
          if (ledgerOutcome.status === 'conflict') conflicts++;
          errors.push({
            filePath: decision.filePath,
            error: ledgerOutcome.error,
            code: ledgerOutcome.code,
          });
        }
        continue;
      }

      if (shouldDeleteNewFile) {
        // If we have an expected modified baseline, guard against deleting a user-modified file.
        if (modified !== null) {
          const conflict = await this.checkConflict(decision.filePath, modified);
          if (conflict.hasConflict) {
            conflicts++;
            errors.push({
              filePath: decision.filePath,
              error:
                'File was modified since review was computed; refusing to delete new file automatically.',
              code: 'conflict',
            });
            continue;
          }
        } else {
          // No baseline — safest behavior is to only treat "already missing" as success.
          try {
            await readFile(decision.filePath, 'utf8');
          } catch {
            applied++;
            continue;
          }
          errors.push({
            filePath: decision.filePath,
            error: 'Cannot delete new file: expected modified content is unavailable.',
            code: 'unavailable',
          });
          continue;
        }

        try {
          await unlink(decision.filePath);
          applied++;
        } catch (err) {
          const msg = String(err);
          if (msg.includes('ENOENT')) {
            applied++;
          } else {
            errors.push({
              filePath: decision.filePath,
              error: `Failed to delete new file: ${msg}`,
              code: 'io-error',
            });
          }
        }
        continue;
      }

      if (original === null || modified === null) {
        errors.push({
          filePath: decision.filePath,
          error: 'Содержимое файла недоступно для применения review',
          code: 'unavailable',
        });
        continue;
      }

      try {
        if (decision.fileDecision === 'rejected') {
          // Reject entire file
          const result = await this.rejectFile(
            request.teamName,
            decision.filePath,
            original,
            modified
          );
          if (result.success) {
            applied++;
          } else {
            if (result.hadConflicts) conflicts++;
            errors.push({
              filePath: decision.filePath,
              error: result.conflictDescription || 'Не удалось применить reject',
            });
          }
        } else {
          // Partial reject — only specific hunks
          if (rejectedHunkIndices.length === 0) {
            skipped++;
            continue;
          }

          const mappedRejected =
            decision.hunkContextHashes && Object.keys(decision.hunkContextHashes).length > 0
              ? mapRejectedHunkIndicesByHash(
                  original,
                  modified,
                  rejectedHunkIndices,
                  decision.hunkContextHashes
                )
              : rejectedHunkIndices;

          const result = await this.rejectHunks(
            request.teamName,
            decision.filePath,
            original,
            modified,
            mappedRejected,
            fileContent.snippets
          );

          if (result.success) {
            applied++;
          } else {
            if (result.hadConflicts) conflicts++;
            errors.push({
              filePath: decision.filePath,
              error: result.conflictDescription || 'Не удалось применить reject',
            });
          }
        }
      } catch (err) {
        errors.push({
          filePath: decision.filePath,
          error: `Неожиданная ошибка: ${String(err)}`,
        });
      }
    }

    return { applied, skipped, conflicts, errors };
  }

  /**
   * Save edited file content directly to disk.
   */
  async saveEditedFile(filePath: string, content: string): Promise<{ success: boolean }> {
    await writeFile(filePath, content, 'utf8');
    return { success: true };
  }

  // ── Private: Rejection strategies ──

  private async tryApplyLedgerDecision(
    filePath: string,
    original: string | null,
    modified: string | null,
    fileRejected: boolean,
    allHunksRejected: boolean,
    rejectedHunkIndices: number[],
    hunkContextHashes: Record<number, string> | undefined,
    snippets: SnippetDiff[]
  ): Promise<LedgerApplyOutcome> {
    const ledgerSnippets = snippets.filter((snippet) => snippet.ledger && !snippet.isError);
    if (ledgerSnippets.length === 0) {
      return { handled: false };
    }

    const firstLedger = ledgerSnippets[0]?.ledger;
    const lastLedger = ledgerSnippets[ledgerSnippets.length - 1]?.ledger;
    if (!firstLedger || !lastLedger) {
      return { handled: false };
    }

    const fullReject = fileRejected || allHunksRejected;
    const hasUnavailableState = ledgerSnippets.some(
      (snippet) =>
        snippet.ledger?.beforeState?.unavailableReason ||
        snippet.ledger?.afterState?.unavailableReason
    );
    const relation = this.resolveLedgerRelation(ledgerSnippets);

    if (hasUnavailableState) {
      return {
        handled: true,
        status: 'error',
        code: 'manual-review-required',
        error: 'Ledger content metadata is unavailable; manual review is required.',
      };
    }

    if (!fullReject) {
      if (relation?.kind === 'rename' || relation?.kind === 'copy') {
        return {
          handled: true,
          status: 'error',
          code: 'manual-review-required',
          error: `Ledger ${relation.kind} partial reject requires manual review.`,
        };
      }
      if (original === null || modified === null) {
        return {
          handled: true,
          status: 'error',
          code: 'manual-review-required',
          error: 'Ledger full text is unavailable; partial reject requires manual review.',
        };
      }
      const strictHunks = mapRejectedHunkIndicesByHashStrict(
        original,
        modified,
        rejectedHunkIndices,
        hunkContextHashes
      );
      if (!strictHunks.ok) {
        return {
          handled: true,
          status: strictHunks.code === 'conflict' ? 'conflict' : 'error',
          code: strictHunks.code,
          error: strictHunks.error,
        };
      }
      const guard = await this.checkLedgerCurrentHash(
        filePath,
        lastLedger.afterState?.sha256 ?? lastLedger.afterHash ?? undefined
      );
      if (!guard.ok) {
        return guard.outcome;
      }
      const patchResult = this.tryStrictHunkLevelReject(original, modified, strictHunks.indices);
      if (!patchResult) {
        return {
          handled: true,
          status: 'error',
          code: 'manual-review-required',
          error: 'Ledger partial reject could not be applied safely.',
        };
      }
      try {
        await writeFile(filePath, patchResult.newContent, 'utf8');
        return { handled: true, status: 'applied' };
      } catch (err) {
        return {
          handled: true,
          status: 'error',
          code: 'io-error',
          error: `Не удалось записать файл: ${String(err)}`,
        };
      }
    }

    if (relation?.kind === 'rename') {
      return this.rejectLedgerRename(ledgerSnippets, relation, original, hasUnavailableState);
    }

    const operation = this.resolveLedgerOperation(ledgerSnippets);
    if (operation === 'create') {
      const afterHash = lastLedger.afterState?.sha256 ?? lastLedger.afterHash ?? undefined;
      const current = await this.readCurrentText(filePath);
      if (current.missing) {
        return { handled: true, status: 'applied' };
      }
      const currentError = getCurrentTextReadError(current);
      if (currentError) {
        return {
          handled: true,
          status: 'error',
          code: 'io-error',
          error: currentError,
        };
      }
      if (!afterHash) {
        return {
          handled: true,
          status: 'error',
          code: hasUnavailableState ? 'manual-review-required' : 'unavailable',
          error: 'Ledger after content hash is unavailable; refusing to delete file automatically.',
        };
      }
      if (this.hashText(current.content) !== afterHash) {
        return {
          handled: true,
          status: 'conflict',
          code: 'conflict',
          error: 'File was modified since review was computed; refusing ledger delete.',
        };
      }
      try {
        await unlink(filePath);
        return { handled: true, status: 'applied' };
      } catch (err) {
        const msg = String(err);
        if (msg.includes('ENOENT')) {
          return { handled: true, status: 'applied' };
        }
        return {
          handled: true,
          status: 'error',
          code: 'io-error',
          error: `Failed to delete new file: ${msg}`,
        };
      }
    }

    if (operation === 'delete') {
      if (original === null) {
        return {
          handled: true,
          status: 'error',
          code: 'manual-review-required',
          error: 'Ledger before content is unavailable; deleted file requires manual restore.',
        };
      }
      const current = await this.readCurrentText(filePath);
      if (!current.missing) {
        const currentError = getCurrentTextReadError(current);
        return {
          handled: true,
          status: 'conflict',
          code: 'conflict',
          error:
            currentError || 'File exists on disk; refusing to overwrite while rejecting delete.',
        };
      }
      try {
        await writeFile(filePath, original, 'utf8');
        return { handled: true, status: 'applied' };
      } catch (err) {
        return {
          handled: true,
          status: 'error',
          code: 'io-error',
          error: `Не удалось записать файл: ${String(err)}`,
        };
      }
    }

    if (original === null) {
      return {
        handled: true,
        status: 'error',
        code: 'manual-review-required',
        error:
          'Ledger before content is unavailable; rejecting this change requires manual review.',
      };
    }
    const guard = await this.checkLedgerCurrentHash(
      filePath,
      lastLedger.afterState?.sha256 ?? lastLedger.afterHash ?? undefined
    );
    if (!guard.ok) {
      return guard.outcome;
    }
    try {
      await writeFile(filePath, original, 'utf8');
      return { handled: true, status: 'applied' };
    } catch (err) {
      return {
        handled: true,
        status: 'error',
        code: 'io-error',
        error: `Не удалось записать файл: ${String(err)}`,
      };
    }
  }

  private resolveLedgerOperation(snippets: SnippetDiff[]): 'create' | 'modify' | 'delete' {
    const ledgerSnippets = snippets.filter((snippet) => snippet.ledger);
    const firstLedger = ledgerSnippets[0]?.ledger;
    const lastLedger = ledgerSnippets[ledgerSnippets.length - 1]?.ledger;
    const baselineExists = firstLedger?.beforeState?.exists;
    const finalExists = lastLedger?.afterState?.exists;

    if (baselineExists === false && finalExists === true) return 'create';
    if (baselineExists === true && finalExists === false) return 'delete';
    if (baselineExists === true && finalExists === true) return 'modify';
    if (baselineExists === false && finalExists === false) return 'create';

    if (lastLedger?.operation === 'delete') return 'delete';
    if (firstLedger?.operation === 'create') return 'create';
    return 'modify';
  }

  private resolveLedgerRelation(snippets: SnippetDiff[]): LedgerChangeRelation | undefined {
    return snippets.find((snippet) => snippet.ledger?.relation)?.ledger?.relation;
  }

  private async rejectLedgerRename(
    snippets: SnippetDiff[],
    relation: LedgerChangeRelation,
    original: string | null,
    hasUnavailableState: boolean
  ): Promise<LedgerApplyOutcome> {
    const oldSnippet =
      snippets.find(
        (snippet) =>
          snippet.ledger?.operation === 'delete' &&
          this.pathMatchesRelationPath(snippet.filePath, relation.oldPath)
      ) ?? snippets.find((snippet) => snippet.ledger?.operation === 'delete');
    const newSnippet =
      snippets.find(
        (snippet) =>
          snippet.ledger?.operation === 'create' &&
          this.pathMatchesRelationPath(snippet.filePath, relation.newPath)
      ) ?? snippets.find((snippet) => snippet.ledger?.operation === 'create');
    const oldFilePath =
      oldSnippet?.filePath ??
      this.resolveRelatedLedgerPath(newSnippet?.filePath, relation.newPath, relation.oldPath);
    const newFilePath = newSnippet?.filePath;
    const oldContent = oldSnippet?.ledger?.originalFullContent ?? original;
    const newHash = newSnippet?.ledger?.afterState?.sha256 ?? newSnippet?.ledger?.afterHash;
    const oldHash = oldSnippet?.ledger?.beforeState?.sha256 ?? oldSnippet?.ledger?.beforeHash;

    if (!oldFilePath || !newFilePath || oldContent === null) {
      return {
        handled: true,
        status: 'error',
        code: 'manual-review-required',
        error: 'Ledger rename metadata is incomplete; manual review is required.',
      };
    }
    if (hasUnavailableState || !newHash) {
      return {
        handled: true,
        status: 'error',
        code: 'manual-review-required',
        error: 'Ledger rename content metadata is unavailable; manual review is required.',
      };
    }

    const newCurrent = await this.readCurrentText(newFilePath);
    if (!newCurrent.missing) {
      const newCurrentError = getCurrentTextReadError(newCurrent);
      if (newCurrentError) {
        return {
          handled: true,
          status: 'error',
          code: 'io-error',
          error: newCurrentError,
        };
      }
      if (this.hashText(newCurrent.content) !== newHash) {
        return {
          handled: true,
          status: 'conflict',
          code: 'conflict',
          error: 'Renamed file was modified since review was computed; refusing ledger reject.',
        };
      }
    }

    const oldCurrent = await this.readCurrentText(oldFilePath);
    if (!oldCurrent.missing) {
      const oldCurrentError = getCurrentTextReadError(oldCurrent);
      if (oldCurrentError) {
        return {
          handled: true,
          status: 'error',
          code: 'io-error',
          error: oldCurrentError,
        };
      }
      if (!oldHash || this.hashText(oldCurrent.content) !== oldHash) {
        return {
          handled: true,
          status: 'conflict',
          code: 'conflict',
          error: 'Original rename path already exists with different content; refusing overwrite.',
        };
      }
    }

    try {
      if (oldCurrent.missing) {
        await mkdir(dirname(oldFilePath), { recursive: true });
        await writeFile(oldFilePath, oldContent, 'utf8');
      }
      if (!newCurrent.missing) {
        await unlink(newFilePath);
      }
      return { handled: true, status: 'applied' };
    } catch (err) {
      return {
        handled: true,
        status: 'error',
        code: 'io-error',
        error: `Failed to reject ledger rename: ${String(err)}`,
      };
    }
  }

  private pathMatchesRelationPath(filePath: string, relationPath: string): boolean {
    const caseInsensitive =
      this.isWindowsReviewPath(filePath) || this.isWindowsReviewPath(relationPath);
    const normalizedFilePath = this.normalizeRelationComparisonPath(filePath, caseInsensitive);
    const normalizedRelationPath = this.normalizeRelationComparisonPath(
      relationPath,
      caseInsensitive
    );
    return (
      normalizedFilePath === normalizedRelationPath ||
      normalizedFilePath.endsWith(`/${normalizedRelationPath}`)
    );
  }

  private resolveRelatedLedgerPath(
    anchorPath: string | undefined,
    anchorRelationPath: string,
    targetRelationPath: string
  ): string | null {
    if (!anchorPath) {
      return null;
    }
    const slashAnchor = anchorPath.replace(/\\/g, '/');
    const slashRelation = anchorRelationPath.replace(/\\/g, '/');
    const caseInsensitive =
      this.isWindowsReviewPath(anchorPath) || this.isWindowsReviewPath(anchorRelationPath);
    const normalizedAnchor = this.normalizeRelationComparisonPath(anchorPath, caseInsensitive);
    const normalizedRelation = this.normalizeRelationComparisonPath(
      anchorRelationPath,
      caseInsensitive
    );
    if (!this.matchesRelationSuffix(normalizedAnchor, normalizedRelation)) {
      return null;
    }
    return `${slashAnchor.slice(0, slashAnchor.length - slashRelation.length)}${targetRelationPath.replace(/\\/g, '/')}`;
  }

  private normalizeRelationComparisonPath(filePath: string, caseInsensitive: boolean): string {
    const normalized = normalizePathForComparison(filePath);
    return caseInsensitive ? normalized.toLowerCase() : normalized;
  }

  private isWindowsReviewPath(filePath: string): boolean {
    return isWindowsishPath(filePath) || filePath.includes('\\');
  }

  private matchesRelationSuffix(normalizedPath: string, normalizedRelationPath: string): boolean {
    return (
      normalizedPath === normalizedRelationPath ||
      normalizedPath.endsWith(`/${normalizedRelationPath}`)
    );
  }

  private async checkLedgerCurrentHash(
    filePath: string,
    expectedHash: string | undefined
  ): Promise<{ ok: true } | { ok: false; outcome: LedgerApplyOutcome }> {
    if (!expectedHash) {
      return {
        ok: false,
        outcome: {
          handled: true,
          status: 'error',
          code: 'manual-review-required',
          error: 'Ledger expected content hash is unavailable; refusing automatic apply.',
        },
      };
    }
    const current = await this.readCurrentText(filePath);
    if (current.missing) {
      return {
        ok: false,
        outcome: {
          handled: true,
          status: 'conflict',
          code: 'conflict',
          error: 'File is missing on disk; refusing ledger apply.',
        },
      };
    }
    const currentError = getCurrentTextReadError(current);
    if (currentError) {
      return {
        ok: false,
        outcome: {
          handled: true,
          status: 'error',
          code: 'io-error',
          error: currentError,
        },
      };
    }
    if (this.hashText(current.content) !== expectedHash) {
      return {
        ok: false,
        outcome: {
          handled: true,
          status: 'conflict',
          code: 'conflict',
          error: 'File was modified since review was computed; refusing ledger apply.',
        },
      };
    }
    return { ok: true };
  }

  private async readCurrentText(filePath: string): Promise<CurrentTextReadResult> {
    try {
      return { missing: false, content: await readFile(filePath, 'utf8') };
    } catch (err) {
      const code =
        err && typeof err === 'object' && 'code' in err
          ? String((err as { code?: unknown }).code)
          : '';
      if (code === 'ENOENT') {
        return { missing: true, content: '' };
      }
      return { missing: false, content: '', error: `Не удалось прочитать файл: ${String(err)}` };
    }
  }

  private hashText(content: string): string {
    return createHash('sha256').update(content).digest('hex');
  }

  /**
   * Snippet-level rejection: reverse specific snippets by position (most accurate).
   *
   * Uses HunkSnippetMatcher with content overlap analysis to map
   * hunk indices → snippet indices, then reverses matched snippets.
   */
  private trySnippetLevelReject(
    original: string,
    modified: string,
    hunkIndices: number[],
    snippets: SnippetDiff[]
  ): RejectResult | null {
    // Safety: never use full-file Write snippets for snippet-level rejection.
    // They are not localized, and matching a single hunk to a full-file write
    // can incorrectly delete/overwrite large parts of the file.
    const validSnippets = snippets.filter(
      (s) =>
        !s.isError &&
        s.type !== 'write-new' &&
        s.type !== 'write-update' &&
        s.type !== 'notebook-edit' &&
        s.type !== 'shell-snapshot' &&
        s.type !== 'hook-snapshot'
    );
    if (validSnippets.length === 0) return null;

    // Pass pre-filtered snippets — matcher returns indices relative to this array
    const hunkToSnippets = this.matcher.matchHunksToSnippets(
      original,
      modified,
      hunkIndices,
      validSnippets
    );

    // Safety: if any requested hunk maps ambiguously, do NOT attempt snippet-level replacement.
    // Fall back to hunk-level inverse patch which is positional and safer.
    for (const hunkIdx of hunkIndices) {
      const set = hunkToSnippets.get(hunkIdx);
      if (set?.size !== 1) {
        return null;
      }
    }

    // Collect all unique snippet indices to reject
    const snippetIndices = new Set<number>();
    for (const indices of hunkToSnippets.values()) {
      indices.forEach((idx) => snippetIndices.add(idx));
    }

    const snippetsToReject = Array.from(snippetIndices)
      .map((idx) => validSnippets[idx])
      .filter(Boolean);

    if (snippetsToReject.length === 0) return null;

    let content = modified;

    // Find positions using disambiguation and sort descending for safe replacement
    const positioned = snippetsToReject
      .map((snippet) => {
        const pos = this.matcher.findSnippetPosition(snippet, content);
        return { snippet, pos };
      })
      .filter((item) => item.pos !== -1)
      .sort((a, b) => b.pos - a.pos);

    if (positioned.length !== snippetsToReject.length) {
      // Some snippets' newStrings not found — can't do snippet-level
      return null;
    }

    for (const { snippet, pos } of positioned) {
      if (snippet.type === 'write-new') {
        // Can't partially reject a file creation at snippet level
        continue;
      }

      if (snippet.replaceAll) {
        content = content.split(snippet.newString).join(snippet.oldString);
      } else {
        content =
          content.substring(0, pos) +
          snippet.oldString +
          content.substring(pos + snippet.newString.length);
      }
    }

    return {
      success: true,
      newContent: content,
      hadConflicts: false,
    };
  }

  /**
   * Hunk-level rejection: create inverse patch for rejected hunks and apply it.
   */
  private tryHunkLevelReject(
    original: string,
    modified: string,
    hunkIndices: number[]
  ): RejectResult | null {
    // Create structured patch
    const patch = structuredPatch('file', 'file', original, modified);

    if (!patch.hunks || patch.hunks.length === 0) return null;

    // Validate hunk indices
    const validIndices = hunkIndices.filter((idx) => idx >= 0 && idx < patch.hunks.length);
    if (validIndices.length === 0) return null;

    // Build a partial inverse patch: only reverse the rejected hunks
    const inversedHunks: StructuredPatchHunk[] = [];
    for (const idx of validIndices) {
      const hunk = patch.hunks[idx];
      if (!hunk) continue;
      inversedHunks.push(invertHunk(hunk));
    }

    if (inversedHunks.length === 0) return null;

    // Create a partial inverse patch with the inverted hunks
    const inversePatch = {
      oldFileName: 'file',
      newFileName: 'file',
      oldHeader: undefined,
      newHeader: undefined,
      hunks: inversedHunks,
    };

    // Apply the inverse patch to the modified content
    const result = applyPatch(modified, inversePatch, { fuzzFactor: 2 });

    if (result === false) {
      logger.debug('Hunk-level inverse patch не удался');
      return null;
    }

    return {
      success: true,
      newContent: result,
      hadConflicts: false,
    };
  }

  private tryStrictHunkLevelReject(
    original: string,
    modified: string,
    hunkIndices: number[]
  ): RejectResult | null {
    const patch = structuredPatch('file', 'file', original, modified);

    if (!patch.hunks || patch.hunks.length === 0) return null;

    const validIndices = hunkIndices.filter((idx) => idx >= 0 && idx < patch.hunks.length);
    if (validIndices.length !== hunkIndices.length || validIndices.length === 0) return null;

    const inversedHunks: StructuredPatchHunk[] = [];
    for (const idx of validIndices) {
      const hunk = patch.hunks[idx];
      if (!hunk) return null;
      inversedHunks.push(invertHunk(hunk));
    }

    const inversePatch = {
      oldFileName: 'file',
      newFileName: 'file',
      oldHeader: undefined,
      newHeader: undefined,
      hunks: inversedHunks,
    };

    const result = applyPatch(modified, inversePatch, { fuzzFactor: 0 });
    if (result === false) {
      logger.debug('Strict ledger hunk-level inverse patch не удался');
      return null;
    }

    return {
      success: true,
      newContent: result,
      hadConflicts: false,
    };
  }
}

function buildHunkHashIndexMap(original: string, modified: string): Map<string, number[]> {
  const patch = structuredPatch('file', 'file', original, modified);
  const hunks = patch.hunks ?? [];
  const map = new Map<string, number[]>();
  for (let i = 0; i < hunks.length; i++) {
    const hunk = hunks[i];
    const oldSideContent = hunk.lines
      .filter((l) => !l.startsWith('+'))
      .map((l) => l.slice(1))
      .join('\n');
    const newSideContent = hunk.lines
      .filter((l) => !l.startsWith('-'))
      .map((l) => l.slice(1))
      .join('\n');
    const hash = computeDiffContextHash(oldSideContent, newSideContent);
    const arr = map.get(hash);
    if (arr) arr.push(i);
    else map.set(hash, [i]);
  }
  return map;
}

function mapRejectedHunkIndicesByHash(
  original: string,
  modified: string,
  rejectedIndices: number[],
  hunkContextHashes: Record<number, string>
): number[] {
  const hashMap = buildHunkHashIndexMap(original, modified);
  const out = new Set<number>();

  for (const idx of rejectedIndices) {
    const hash = hunkContextHashes[idx];
    if (!hash) {
      out.add(idx);
      continue;
    }
    const candidates = hashMap.get(hash);
    if (candidates?.length === 1) {
      out.add(candidates[0]);
    } else {
      // Ambiguous or missing — fall back to index to preserve prior behavior.
      out.add(idx);
    }
  }

  return [...out].sort((a, b) => a - b);
}

function mapRejectedHunkIndicesByHashStrict(
  original: string,
  modified: string,
  rejectedIndices: number[],
  hunkContextHashes: Record<number, string> | undefined
): { ok: true; indices: number[] } | { ok: false; code: ApplyErrorCode; error: string } {
  if (rejectedIndices.length === 0) {
    return { ok: true, indices: [] };
  }
  if (!hunkContextHashes || Object.keys(hunkContextHashes).length === 0) {
    return {
      ok: false,
      code: 'manual-review-required',
      error: 'Ledger partial reject requires stable hunk context hashes.',
    };
  }

  const hashMap = buildHunkHashIndexMap(original, modified);
  const out = new Set<number>();
  for (const idx of rejectedIndices) {
    const hash = hunkContextHashes[idx];
    if (!hash) {
      return {
        ok: false,
        code: 'manual-review-required',
        error: 'Ledger partial reject is missing a hunk context hash.',
      };
    }
    const candidates = hashMap.get(hash);
    if (!candidates || candidates.length === 0) {
      return {
        ok: false,
        code: 'conflict',
        error: 'Ledger partial reject hunk context changed; please re-review.',
      };
    }
    if (candidates.length > 1) {
      return {
        ok: false,
        code: 'conflict',
        error: 'Ledger partial reject hunk context is ambiguous; please re-review.',
      };
    }
    out.add(candidates[0]);
  }
  return { ok: true, indices: [...out].sort((a, b) => a - b) };
}

// ── Module-level helpers ──

/**
 * Invert a single hunk: swap added/removed lines, swap old/new start/lines.
 */
function invertHunk(hunk: StructuredPatchHunk): StructuredPatchHunk {
  const invertedLines = hunk.lines.map((line) => {
    if (line.startsWith('+')) return '-' + line.substring(1);
    if (line.startsWith('-')) return '+' + line.substring(1);
    return line; // context lines remain unchanged
  });

  return {
    oldStart: hunk.newStart,
    oldLines: hunk.newLines,
    newStart: hunk.oldStart,
    newLines: hunk.oldLines,
    lines: invertedLines,
  };
}

/**
 * Three-way merge using node-diff3.
 *
 * @param base   base version (common ancestor)
 * @param ours   "our" version (current state)
 * @param theirs "their" version (desired state)
 * @returns merged content and conflict indicator
 */
function threeWayMerge(
  base: string,
  ours: string,
  theirs: string
): { content: string; hasConflicts: boolean } {
  const regions = diff3Merge(ours, base, theirs);
  let hasConflicts = false;
  const parts: string[] = [];

  for (const region of regions) {
    if (region.ok) {
      parts.push(region.ok.join('\n'));
    } else if (region.conflict) {
      hasConflicts = true;
      // Include conflict markers for visibility
      parts.push('<<<<<<< current');
      parts.push(region.conflict.a.join('\n'));
      parts.push('=======');
      parts.push(region.conflict.b.join('\n'));
      parts.push('>>>>>>> original');
    }
  }

  return {
    content: parts.join('\n'),
    hasConflicts,
  };
}
