from dataclasses import dataclass
from typing import Optional, List, Dict, Any
import asyncio
import difflib
import json
import os
import re
import shutil
import subprocess
import tempfile
import time
from pathlib import Path

# --- Configuration Constants ---
MATCH_TEXT_MAX_LENGTH = 2000
NEW_STRING_MAX_LENGTH = 5_000_000  # 5MB - prevent OOM from huge replacements
MAX_FILE_SIZE = 50_000_000  # 50MB - prevent OOM from reading huge files
# Token-efficient error hint threshold - we NEVER dump full file content
# Instead, we provide fuzzy suggestions and file outlines
ERROR_HINT_FUZZY_THRESHOLD = 0.6  # Minimum similarity for fuzzy suggestions
ERROR_HINT_MAX_SUGGESTIONS = 3    # Max fuzzy match suggestions to return
ERROR_HINT_PREVIEW_LENGTH = 200   # Max chars for preview snippets


def find_similar_blocks(match_text: str, file_content: str, cutoff: float = ERROR_HINT_FUZZY_THRESHOLD) -> List[Dict[str, Any]]:
    """
    Find text blocks in file that are similar to match_text using difflib.
    Returns top matches with line numbers and previews for token-efficient hints.
    """
    if not match_text or not file_content:
        return []

    lines = file_content.split('\n')
    match_lines = match_text.split('\n')
    match_len = len(match_lines)

    candidates = []
    for i in range(max(1, len(lines) - match_len + 1)):
        block = '\n'.join(lines[i:i + match_len])
        ratio = difflib.SequenceMatcher(None, match_text, block).ratio()
        if ratio >= cutoff:
            preview = block[:ERROR_HINT_PREVIEW_LENGTH]
            if len(block) > ERROR_HINT_PREVIEW_LENGTH:
                preview += '...'
            candidates.append({
                'line_start': i + 1,
                'line_end': i + match_len,
                'similarity': round(ratio * 100),  # Percentage for readability
                'preview': preview
            })

    # Return top matches sorted by similarity
    return sorted(candidates, key=lambda x: x['similarity'], reverse=True)[:ERROR_HINT_MAX_SUGGESTIONS]


def extract_file_outline(content: str, file_path: str = "") -> List[Dict[str, Any]]:
    """
    Extract structural outline of a file (classes, functions, methods).
    Uses regex patterns that work across common languages.
    Returns symbols with line numbers for navigation hints.
    """
    if not content:
        return []

    lines = content.split('\n')
    outline = []

    # Determine language from extension
    suffix = Path(file_path).suffix.lower() if file_path else ""

    # Language-specific patterns
    patterns = []
    if suffix in ('.py', ''):
        patterns = [
            (r'^class\s+(\w+)', 'class'),
            (r'^async\s+def\s+(\w+)', 'async function'),
            (r'^def\s+(\w+)', 'function'),
            (r'^(\s+)async\s+def\s+(\w+)', 'async method'),
            (r'^(\s+)def\s+(\w+)', 'method'),
        ]
    elif suffix in ('.js', '.ts', '.jsx', '.tsx', '.mjs'):
        patterns = [
            (r'^class\s+(\w+)', 'class'),
            (r'^(?:export\s+)?(?:async\s+)?function\s+(\w+)', 'function'),
            (r'^(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?\(', 'arrow function'),
            (r'^\s+(?:async\s+)?(\w+)\s*\([^)]*\)\s*\{', 'method'),
        ]
    elif suffix in ('.go',):
        patterns = [
            (r'^func\s+(\w+)', 'function'),
            (r'^func\s+\([^)]+\)\s+(\w+)', 'method'),
            (r'^type\s+(\w+)\s+struct', 'struct'),
            (r'^type\s+(\w+)\s+interface', 'interface'),
        ]
    elif suffix in ('.rs',):
        patterns = [
            (r'^(?:pub\s+)?fn\s+(\w+)', 'function'),
            (r'^(?:pub\s+)?struct\s+(\w+)', 'struct'),
            (r'^(?:pub\s+)?impl\s+(\w+)', 'impl'),
            (r'^(?:pub\s+)?trait\s+(\w+)', 'trait'),
        ]
    elif suffix in ('.java', '.kt', '.scala'):
        patterns = [
            (r'^(?:public|private|protected)?\s*class\s+(\w+)', 'class'),
            (r'^(?:public|private|protected)?\s*interface\s+(\w+)', 'interface'),
            (r'^\s+(?:public|private|protected)?\s*(?:static\s+)?(?:\w+\s+)?(\w+)\s*\(', 'method'),
        ]
    else:
        # Generic fallback patterns
        patterns = [
            (r'^class\s+(\w+)', 'class'),
            (r'^(?:def|function|func)\s+(\w+)', 'function'),
            (r'^\s+(?:def|function)\s+(\w+)', 'method'),
        ]

    for i, line in enumerate(lines, 1):
        for pattern, symbol_type in patterns:
            match = re.match(pattern, line)
            if match:
                name = match.group(1) if match.lastindex else match.group(0)
                # For methods, extract actual name (might be in group 2)
                if symbol_type == 'method' and match.lastindex and match.lastindex >= 2:
                    name = match.group(2)
                signature = line.strip()[:100]  # Truncate long signatures
                outline.append({
                    'type': symbol_type,
                    'name': name,
                    'line': i,
                    'signature': signature
                })
                if len(outline) >= 500:  # Cap the number of symbols
                    return outline
                break  # Only match one pattern per line

    return outline


def _extract_grep_keywords(match_text: str) -> List[str]:
    """Extract distinctive keywords from match_text for grep suggestions."""
    # Look for function/class names, distinctive identifiers
    keywords = []

    # Function/method names
    func_match = re.search(r'(?:def|function|func|fn)\s+(\w+)', match_text)
    if func_match:
        keywords.append(func_match.group(1))

    # Class names
    class_match = re.search(r'class\s+(\w+)', match_text)
    if class_match:
        keywords.append(class_match.group(1))

    # Variable assignments with distinctive names
    var_matches = re.findall(r'(\w{4,})\s*[=:]', match_text)
    keywords.extend(var_matches[:2])

    # String literals (useful for error messages, etc.)
    string_matches = re.findall(r'["\']([^"\']{10,40})["\']', match_text)
    keywords.extend(string_matches[:1])

    # Deduplicate while preserving order
    seen = set()
    unique = []
    for kw in keywords:
        if kw.lower() not in seen:
            seen.add(kw.lower())
            unique.append(kw)

    return unique[:3]  # Return top 3 keywords


def generate_token_efficient_hint(
    match_text: str,
    file_content: str,
    file_path: str,
    error_context: str = ""
) -> Dict[str, Any]:
    """
    Generate token-efficient error hints instead of dumping full file content.

    Returns a dict with:
    - 'hint': Human-readable guidance message
    - 'suggestions': Fuzzy match suggestions with line numbers (if found)
    - 'outline': File structure outline (if no good fuzzy matches)
    - 'line_count': Total lines in file
    - 'recovery_steps': Concrete steps to recover from error
    """
    line_count = file_content.count('\n') + 1
    result = {'line_count': line_count}

    # Strategy 1: Find fuzzy matches
    suggestions = find_similar_blocks(match_text, file_content)

    if suggestions:
        best_match = suggestions[0]
        result['suggestions'] = suggestions
        # Add warning about preview limitations (per intern test feedback)
        result['preview_warning'] = "Previews may hide whitespace differences (tabs/spaces/line endings). Always verify with read_files."

        if best_match['similarity'] >= 90:
            result['hint'] = (
                f"Found very similar text ({best_match['similarity']}% character match) at lines {best_match['line_start']}-{best_match['line_end']}. "
                f"Likely a whitespace or minor difference.{error_context}"
            )
            result['recovery_steps'] = [
                f"1. Call read_files with path='{file_path}', start_line={best_match['line_start']}, end_line={best_match['line_end']}, compact=False",
                "2. IMPORTANT: Use compact=False to get EXACT verbatim content (default compact=True strips comments/whitespace)",
                "3. Compare output character-by-character with your match_text (check tabs vs spaces, trailing whitespace)",
                "4. Copy the EXACT text from read_files output as your new match_text",
                "5. Retry edit_files with corrected match_text"
            ]
        else:
            result['hint'] = (
                f"Found {len(suggestions)} similar block(s). Best: {best_match['similarity']}% at lines {best_match['line_start']}-{best_match['line_end']}.{error_context}"
            )
            result['recovery_steps'] = [
                f"1. Call read_files with path='{file_path}', start_line={best_match['line_start']}, end_line={best_match['line_end']}, compact=False",
                "2. IMPORTANT: Use compact=False to get EXACT verbatim content (default compact=True strips comments/whitespace)",
                "3. Verify this is the correct code section you intended to edit",
                "4. Copy the EXACT text from read_files output as your new match_text",
                "5. Retry edit_files with corrected match_text"
            ]
    else:
        # Strategy 2: Provide file outline when no fuzzy matches found
        outline = extract_file_outline(file_content, file_path)
        grep_keywords = _extract_grep_keywords(match_text)

        if outline:
            result['outline'] = outline
            if grep_keywords:
                result['suggested_grep_keywords'] = grep_keywords
                result['hint'] = (
                    f"No similar text found in {line_count}-line file. "
                    f"Try grep_content with keywords: {', '.join(repr(k) for k in grep_keywords)}.{error_context}"
                )
                result['recovery_steps'] = [
                    f"1. Call grep_content with pattern='{grep_keywords[0]}', compact=False to locate the code with section hints",
                    "2. Note the line numbers from grep results",
                    f"3. Call read_files with path='{file_path}', line range from grep, and compact=False for verbatim content",
                    "4. Copy the EXACT text and retry edit_files"
                ]
            else:
                result['hint'] = (
                    f"No similar text found in {line_count}-line file. File structure shown in 'outline'. "
                    f"Use grep_content to locate the target code.{error_context}"
                )
                result['recovery_steps'] = [
                    "1. Review the 'outline' to identify which function/class contains your target",
                    "2. Call grep_content with a distinctive keyword from your match_text (use compact=False for section hints)",
                    f"3. Call read_files with path='{file_path}', line range, and compact=False for verbatim content",
                    "4. Copy the EXACT text and retry edit_files"
                ]
        else:
            result['hint'] = (
                f"No match found in {line_count}-line file. The match_text may be outdated or from a different file.{error_context}"
            )
            if grep_keywords:
                result['suggested_grep_keywords'] = grep_keywords
            result['recovery_steps'] = [
                "1. Verify you're editing the correct file path",
                f"2. Call grep_content to search for keywords from your match_text (use compact=False for section hints)",
                "3. Call read_files with compact=False to get the EXACT verbatim content",
                "4. Update match_text to reflect current file state"
            ]

    return result


# --- Unicode Confusable Recovery ---
# LLMs systematically produce typographic Unicode characters (curly quotes,
# em-dashes, ellipsis) when reconstructing match_text from context, even though
# the actual file contains ASCII equivalents. This causes 100%-similarity
# match failures. Rather than maintaining a confusables map, we use the
# existing fuzzy matching infrastructure: when similarity >= 99%, auto-recover
# by substituting the actual file text for the LLM-generated match_text.
FUZZY_AUTO_RECOVER_THRESHOLD = 0.99  # 99% similarity for fuzzy fallback

# Unicode confusable normalization map: LLM-produced chars -> ASCII equivalents
_CONFUSABLE_MAP = {
    "‘": "'",   # LEFT SINGLE QUOTATION MARK
    "’": "'",   # RIGHT SINGLE QUOTATION MARK
    "“": '"',   # LEFT DOUBLE QUOTATION MARK
    "”": '"',   # RIGHT DOUBLE QUOTATION MARK
    "…": "...", # HORIZONTAL ELLIPSIS
    "–": "-",   # EN DASH
    "—": "--",  # EM DASH
    " ": " ",   # NON-BREAKING SPACE
    "‐": "-",   # HYPHEN
    "‑": "-",   # NON-BREAKING HYPHEN
    "‒": "-",   # FIGURE DASH
    "‚": "'",   # SINGLE LOW-9 QUOTATION MARK
    "„": '"',   # DOUBLE LOW-9 QUOTATION MARK
    "′": "'",   # PRIME
    "″": '"',   # DOUBLE PRIME
}

def _normalize_confusables(text: str) -> str:
    """Replace known Unicode confusables with ASCII equivalents."""
    for unicode_char, ascii_equiv in _CONFUSABLE_MAP.items():
        text = text.replace(unicode_char, ascii_equiv)
    return text


def _try_fuzzy_recover(match_text: str, file_content: str) -> Optional[str]:
    """Attempt to recover from a near-miss match caused by Unicode confusables.

    Strategy:
    1. Normalize Unicode confusables in match_text to ASCII equivalents.
    2. If normalized text has exactly 1 occurrence in file_content -> return it.
    3. Fallback: use fuzzy matching with a 90% threshold.

    Returns the actual file text if recovery succeeds, None otherwise.
    """
    # Strategy 1: Deterministic confusable normalization
    normalized = _normalize_confusables(match_text)
    if normalized != match_text:  # only if normalization changed something
        occurrences = file_content.count(normalized)
        if occurrences == 1:
            return normalized

    # Strategy 2: Fuzzy matching fallback for other near-misses
    suggestions = find_similar_blocks(match_text, file_content, cutoff=FUZZY_AUTO_RECOVER_THRESHOLD)
    if not suggestions:
        return None
    best = suggestions[0]
    if best["similarity"] < 99:
        return None
    # Extract the actual file text at the matched position
    lines = file_content.split("\n")
    actual_text = "\n".join(lines[best["line_start"] - 1 : best["line_end"]])
    # Final sanity check: the actual text must exist exactly once in the file
    if file_content.count(actual_text) != 1:
        return None
    return actual_text


OVERWRITE_SENTINEL = "OVERWRITE_FILE"
APPEND_SENTINEL = "APPEND_TO_FILE"

# Backward compatibility alias
OLD_STRING_MAX_LENGTH = MATCH_TEXT_MAX_LENGTH

# The new structure for returning detailed results from the edit tool.
@dataclass
class EditResult:
    success: bool
    message: str
    diff: Optional[str] = None
    error_type: Optional[str] = None
    original_content: Optional[str] = None
    new_content: Optional[str] = None


class RooStyleEditTool:
    """A robust, agent-friendly file editing tool."""
    def __init__(self, validate_path_func):
        self.validate_path = validate_path_func

    def count_occurrences(self, content: str, substr: str) -> int:
        return content.count(substr) if substr else 0
    def normalize_line_endings(self, content: str) -> str:
        return content.replace('\r\n', '\n').replace('\r', '\n')


    def _prepare_edit(self, file_path: str, match_text: str, new_string: str, expected_replacements: int) -> EditResult:
        p = self.validate_path(file_path)
        file_exists = p.exists()
        is_new_file = not file_exists and match_text == ""
        if not file_exists and not is_new_file:
            return EditResult(success=False, message=f"File not found: {file_path}", error_type="file_not_found")
        if file_exists and is_new_file:
            return EditResult(success=False, message=f"File '{file_path}' already exists.", error_type="file_exists")
        original_content = p.read_text(encoding='utf-8') if file_exists else ""

        normalized_content = self.normalize_line_endings(original_content)
        normalized_match = self.normalize_line_endings(match_text)

        if not is_new_file:
            if match_text == new_string:
                return EditResult(success=False, message="No changes to apply.", error_type="validation_error")

            # If match_text is empty, it's a full rewrite of an existing file.
            if match_text == APPEND_SENTINEL:
                new_content = normalized_content + new_string
            elif not match_text:
                new_content = new_string
            else:
                occurrences = self.count_occurrences(normalized_content, normalized_match)
                if occurrences == 0:
                    # Try fuzzy recovery (Unicode confusables, etc.)
                    recovered = _try_fuzzy_recover(normalized_match, normalized_content)
                    if recovered is not None:
                        normalized_match = recovered
                        occurrences = 1  # recovery guarantees exactly 1
                    else:
                        return EditResult(success=False, message="No match found for 'match_text'.", error_type="validation_error")
                if occurrences != expected_replacements:
                    return EditResult(success=False, message=f"Expected {expected_replacements} occurrences but found {occurrences}.", error_type="validation_error")
                new_content = normalized_content.replace(normalized_match, new_string)
        else:
            new_content = new_string

        return EditResult(success=True, message="Edit prepared.", original_content=original_content, new_content=new_content)


def _normalize_edit_pairs(edits: Optional[list]) -> Optional[List[dict]]:
    """Normalize EditPair objects/dicts to plain dicts for consistent handling."""
    if not edits:
        return None
    if not isinstance(edits, list) or len(edits) == 0:
        raise ValueError("'edits' must be a non-empty list.")

    normalized = []
    for pair in edits:
        if hasattr(pair, 'model_dump'):  # Pydantic v2
            normalized.append(pair.model_dump())
        elif hasattr(pair, 'dict'):  # Pydantic v1
            normalized.append(pair.dict())
        elif isinstance(pair, dict):
            normalized.append(pair)
        else:
            raise ValueError(f"Edit must be a dict or EditPair, got {type(pair)}")

    for i, pair in enumerate(normalized):
        if not isinstance(pair, dict) or 'match_text' not in pair or 'new_string' not in pair:
            raise ValueError(f"Edit at index {i} must have 'match_text' and 'new_string' keys.")
    return normalized


def _validate_match_texts(
    validate_path,
    path: str,
    edit_pairs: Optional[List[dict]],
    match_text: str = "",
    bypass_match_text_limit: bool = False,
) -> tuple:
    """Validate match_texts and convert sentinels. Returns (edit_pairs, match_text) after conversion."""
    match_texts_to_validate = []
    if edit_pairs:
        match_texts_to_validate = [pair['match_text'] for pair in edit_pairs]
    else:
        match_texts_to_validate = [match_text]

    for idx, mt_val in enumerate(match_texts_to_validate):
        if mt_val == "" or (mt_val is not None and mt_val.strip() == ""):
            p = validate_path(path)
            if p.exists():
                file_content = p.read_text(encoding='utf-8')
                if file_content.strip() != "":
                    error_msg = (
                        "ERROR: match_text is empty but file has content. "
                        "You MUST provide the exact text you want to replace. "
                        "Use read_files or grep_content first to get the current content, then provide "
                        "the EXACT lines you want to change in match_text. "
                        f"For intentional full-file overwrites, pass match_text='{OVERWRITE_SENTINEL}'."
                    )
                    if edit_pairs:
                        error_msg = f"Edit {idx}: {error_msg}"
                    raise ValueError(error_msg)
        elif mt_val == OVERWRITE_SENTINEL:
            if edit_pairs:
                edit_pairs[idx]['match_text'] = ""
            else:
                match_text = ""
        elif mt_val == APPEND_SENTINEL:
            p = validate_path(path)
            if not p.exists():
                raise ValueError(f"File must exist for {APPEND_SENTINEL}.")

    for idx, mt_val in enumerate(match_texts_to_validate):
        if mt_val and mt_val not in [OVERWRITE_SENTINEL, APPEND_SENTINEL] and len(mt_val) > MATCH_TEXT_MAX_LENGTH:
            if bypass_match_text_limit:
                continue
            error_msg = (
                f"ERROR: match_text is too long (over {MATCH_TEXT_MAX_LENGTH} characters). "
                "RECOMMENDED: Break your change into multiple smaller edits using the 'edits' parameter, "
                f"each match_text under {MATCH_TEXT_MAX_LENGTH} chars. "
                "LAST RESORT: If you genuinely need to replace a large contiguous section (e.g., updating a large markdown block), "
                "set bypass_match_text_limit=True to override this limit."
            )
            if edit_pairs:
                error_msg = f"Edit {idx}: {error_msg}"
            raise ValueError(error_msg)

    return edit_pairs, match_text


def _apply_edits_to_content(
    tool: 'RooStyleEditTool',
    content: str,
    edit_pairs: List[dict],
    path: str,
    error_context_prefix: str = "",
) -> str:
    """Apply a list of edits to content string. Returns modified content. Raises ValueError on failure."""
    normalized = tool.normalize_line_endings(content)

    for i, pair in enumerate(edit_pairs):
        mt = tool.normalize_line_endings(pair['match_text'])
        new_s = pair['new_string']
        if mt == APPEND_SENTINEL:
            normalized += new_s
            continue
        if mt:
            occurrences = tool.count_occurrences(normalized, mt)
            if occurrences == 0:
                # Try fuzzy recovery (Unicode confusables, etc.)
                recovered = _try_fuzzy_recover(mt, normalized)
                if recovered is not None:
                    mt = recovered
                    occurrences = 1  # recovery guarantees exactly 1
                else:
                    error_response = {
                        "error": True,
                        "error_type": "validation_error",
                        "message": f"Edit {i}: No match found for 'match_text'.",
                    }
                    hint_info = generate_token_efficient_hint(mt, content, path, f" ({error_context_prefix}edit {i})")
                    error_response.update(hint_info)
                    raise ValueError(json.dumps(error_response, indent=2))
            if occurrences != 1:
                error_response = {
                    "error": True,
                    "error_type": "validation_error",
                    "message": f"Edit {i}: Expected 1 occurrence but found {occurrences}. Provide more context in match_text to ensure uniqueness.",
                }
                hint_info = generate_token_efficient_hint(mt, content, path, f" ({error_context_prefix}edit {i})")
                error_response.update(hint_info)
                raise ValueError(json.dumps(error_response, indent=2))
            normalized = normalized.replace(mt, new_s, 1)
        else:
            # Empty match_text in a multi-edit means full rewrite (only valid as sole edit)
            if len(edit_pairs) > 1:
                raise ValueError("Edit with empty match_text (full rewrite) cannot be combined with other edits.")
            normalized = new_s

    return normalized


def _is_binary_file(path: Path) -> bool:
    """Check if file is binary by looking for null bytes in first 8KB."""
    try:
        with open(path, 'rb') as f:
            chunk = f.read(8192)
            return b'\x00' in chunk
    except Exception:
        return False


def _atomic_write(path: Path, content: str, encoding: str = 'utf-8') -> None:
    """Write content atomically via temp file + rename. Prevents corruption on disk-full/crash."""
    fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix='.tmp')
    try:
        with os.fdopen(fd, 'w', encoding=encoding) as f:
            f.write(content)
        os.replace(tmp_path, path)
    except Exception:
        # Clean up temp file on failure, original file preserved
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise


def _preserve_trailing_newline(original: str, edited: str) -> str:
    """Preserve original file's trailing newline style in edited content."""
    if original.endswith('\n') and not edited.endswith('\n'):
        return edited + '\n'
    if not original.endswith('\n') and edited.endswith('\n'):
        return edited.rstrip('\n')
    return edited


def apply_file_edits(
    validate_path,
    path: str,
    edits: List[dict],
) -> dict:
    """Apply edits to a single file. Returns result dict with status. No sessions, no review.

    Returns:
        {"status": "ok", "edits_applied": N} on success
        {"status": "created"} for new file creation
        {"status": "error", "error": "...", ...hints...} on failure
    """
    tool = RooStyleEditTool(validate_path)
    edit_pairs = _normalize_edit_pairs(edits)
    if not edit_pairs:
        return {"status": "error", "error": "No edits provided."}

    # Validate new_string sizes
    for i, pair in enumerate(edit_pairs):
        if len(pair.get('new_string', '')) > NEW_STRING_MAX_LENGTH:
            return {"status": "error", "error": f"Edit {i}: new_string too large ({len(pair['new_string'])} chars, max {NEW_STRING_MAX_LENGTH})."}

    try:
        edit_pairs, _ = _validate_match_texts(validate_path, path, edit_pairs)
    except ValueError as e:
        error_str = str(e)
        try:
            return {"status": "error", **json.loads(error_str)}
        except json.JSONDecodeError:
            return {"status": "error", "error": error_str}

    p = validate_path(path)

    # Determine if this is a new file creation
    is_new_file = not p.exists() and len(edit_pairs) == 1 and edit_pairs[0]['match_text'] == ""
    if is_new_file:
        p.parent.mkdir(parents=True, exist_ok=True)
        content = edit_pairs[0]['new_string']
        try:
            _atomic_write(p, content)
        except PermissionError as e:
            return {"status": "error", "error": f"Permission denied: {e}"}
        except OSError as e:
            return {"status": "error", "error": f"Write failed: {e}"}
        return {"status": "created"}

    if not p.exists():
        return {"status": "error", "error": f"File not found: {path}"}

    # Safety: reject binary files
    if _is_binary_file(p):
        return {"status": "error", "error": f"Cannot edit binary file: {path}. Use write_file for binary content."}

    # Safety: reject very large files
    file_size = p.stat().st_size
    if file_size > MAX_FILE_SIZE:
        return {"status": "error", "error": f"File too large ({file_size} bytes, max {MAX_FILE_SIZE}). Split edits or use write_file."}

    original_content = p.read_text(encoding='utf-8')

    try:
        new_content = _apply_edits_to_content(tool, original_content, edit_pairs, path)
    except ValueError as e:
        error_str = str(e)
        try:
            return {"status": "error", **json.loads(error_str)}
        except json.JSONDecodeError:
            return {"status": "error", "error": error_str}

    # Preserve original trailing newline style
    final_content = _preserve_trailing_newline(original_content, new_content)

    # Skip write if content unchanged (no-op detection)
    if final_content == original_content:
        return {"status": "ok", "edits_applied": 0, "note": "No changes detected."}

    # Atomic write: temp file + rename prevents corruption on disk-full/crash
    try:
        _atomic_write(p, final_content)
    except PermissionError as e:
        return {"status": "error", "error": f"Permission denied: {e}"}
    except OSError as e:
        return {"status": "error", "error": f"Write failed (disk full?): {e}. Original file preserved."}

    return {"status": "ok", "edits_applied": len(edit_pairs)}


async def propose_and_review_logic(
    validate_path,
    IS_VSCODE_CLI_AVAILABLE,
    path: str,
    new_string: str,
    match_text: str = "",
    expected_replacements: int = 1,
    session_path: Optional[str] = None,
    edits: Optional[list] = None,
    bypass_match_text_limit: bool = False,
    dangerous_skip_permissions: bool = False,
) -> str:
    # --- Validate multi-edit parameter ---
    edit_pairs = _normalize_edit_pairs(edits)

    # --- Validation: Prevent accidental file overwrite ---
    edit_pairs, match_text = _validate_match_texts(
        validate_path, path, edit_pairs, match_text, bypass_match_text_limit
    )

    tool = RooStyleEditTool(validate_path)
    original_path_obj = Path(path)
    active_proposal_content = ""

    # --- Step 1: Determine Intent and Prepare Session ---
    if session_path:
        # --- INTENT: CONTINUING AN EXISTING SESSION ---
        temp_dir = Path(session_path)
        if not temp_dir.is_dir():
            raise ValueError(f"Session path {session_path} does not exist.")

        current_file_path = temp_dir / f"current_{original_path_obj.name}"
        future_file_path = temp_dir / f"future_{original_path_obj.name}"

        staged_content = current_file_path.read_text(encoding='utf-8')

        if edit_pairs:
            # --- MULTI-EDIT CONTINUATION ---
            normalized = tool.normalize_line_endings(staged_content)
            for i, pair in enumerate(edit_pairs):
                mt = tool.normalize_line_endings(pair['match_text'])
                new_s = pair['new_string']
                if mt == APPEND_SENTINEL:
                    normalized += new_s
                    continue
                if mt:
                    occurrences = tool.count_occurrences(normalized, mt)
                    if occurrences != 1:
                        error_response = {
                            "error": True,
                            "error_type": "validation_error",
                            "message": f"Edit {i}: match_text found {occurrences} times in session content, expected 1.",
                        }
                        hint_info = generate_token_efficient_hint(mt, staged_content, path, f" (session edit {i})")
                        error_response.update(hint_info)
                        raise ValueError(json.dumps(error_response, indent=2))
                    normalized = normalized.replace(mt, new_s, 1)
                else:
                    normalized = new_s
            active_proposal_content = normalized
            future_file_path.write_text(active_proposal_content, encoding='utf-8')
        else:
            # --- SINGLE-EDIT CONTINUATION ---
            if match_text == APPEND_SENTINEL:
                active_proposal_content = staged_content + new_string
            else:
                occurrences = tool.count_occurrences(staged_content, match_text)

                if occurrences != 1:
                    error_response = {
                        "error": True,
                        "error_type": "validation_error",
                        "message": f"Contextual patch failed. The provided 'match_text' was found {occurrences} times in the user's last version, but expected exactly 1.",
                    }
                    hint_info = generate_token_efficient_hint(match_text, staged_content, path, " (session)")
                    error_response.update(hint_info)
                    raise ValueError(json.dumps(error_response, indent=2))

                active_proposal_content = staged_content.replace(match_text, new_string, 1)

            future_file_path.write_text(active_proposal_content, encoding='utf-8')
        

    else:
        # --- INTENT: STARTING A NEW SESSION ---
        temp_dir = Path(tempfile.mkdtemp(prefix="mcp_review_"))
        current_file_path = temp_dir / f"current_{original_path_obj.name}"
        future_file_path = temp_dir / f"future_{original_path_obj.name}"

        if edit_pairs:
            # --- MULTI-EDIT MODE ---
            p = validate_path(path)
            if not p.exists():
                if temp_dir.exists(): shutil.rmtree(temp_dir)
                raise ValueError(f"File not found: {path}")
            original_content = p.read_text(encoding='utf-8')
            normalized = tool.normalize_line_endings(original_content)

            for i, pair in enumerate(edit_pairs):
                mt = tool.normalize_line_endings(pair['match_text'])
                new_s = pair['new_string']
                if mt == APPEND_SENTINEL:
                    normalized += new_s
                    continue
                if mt:
                    occurrences = tool.count_occurrences(normalized, mt)
                    if occurrences == 0:
                        # Try fuzzy recovery (Unicode confusables, etc.)
                        recovered = _try_fuzzy_recover(mt, normalized)
                        if recovered is not None:
                            mt = recovered
                            occurrences = 1  # recovery guarantees exactly 1
                        else:
                            if temp_dir.exists(): shutil.rmtree(temp_dir)
                            error_response = {
                                "error": True,
                                "error_type": "validation_error",
                                "message": f"Edit {i}: No match found for 'match_text'.",
                            }
                            hint_info = generate_token_efficient_hint(mt, original_content, path, f" (edit {i})")
                            error_response.update(hint_info)
                            raise ValueError(json.dumps(error_response, indent=2))
                    if occurrences != 1:
                        if temp_dir.exists(): shutil.rmtree(temp_dir)
                        error_response = {
                            "error": True,
                            "error_type": "validation_error",
                            "message": f"Edit {i}: Expected 1 occurrence but found {occurrences}. Provide more context in match_text to ensure uniqueness.",
                        }
                        hint_info = generate_token_efficient_hint(mt, original_content, path, f" (edit {i})")
                        error_response.update(hint_info)
                        raise ValueError(json.dumps(error_response, indent=2))
                    normalized = normalized.replace(mt, new_s, 1)
                else:
                    # Empty match_text in a multi-edit means full rewrite (only valid as sole edit)
                    if len(edit_pairs) > 1:
                        if temp_dir.exists(): shutil.rmtree(temp_dir)
                        raise ValueError("Edit with empty match_text (full rewrite) cannot be combined with other edits.")
                    normalized = new_s

            current_file_path.write_text(original_content, encoding='utf-8')
            active_proposal_content = normalized
            future_file_path.write_text(active_proposal_content, encoding='utf-8')
        else:
            # --- SINGLE-EDIT MODE (original behavior) ---
            prep_result = tool._prepare_edit(path, match_text, new_string, expected_replacements)
            if not prep_result.success:
                if temp_dir.exists(): shutil.rmtree(temp_dir)
                error_response = {
                    "error": True,
                    "error_type": prep_result.error_type,
                    "message": f"Edit preparation failed: {prep_result.message}",
                }
                if prep_result.error_type == "validation_error" and original_path_obj.exists():
                    content = original_path_obj.read_text(encoding='utf-8')
                    hint_info = generate_token_efficient_hint(match_text, content, path)
                    error_response.update(hint_info)
                raise ValueError(json.dumps(error_response, indent=2))

            if prep_result.original_content is not None:
                current_file_path.write_text(prep_result.original_content, encoding='utf-8')
            active_proposal_content = prep_result.new_content
            if active_proposal_content is not None:
                future_file_path.write_text(active_proposal_content, encoding='utf-8')

    # --- Step 2: Dangerous mode bypass (skip interactive human review) ---
    if dangerous_skip_permissions:
        original_file = validate_path(path)
        final_content = (active_proposal_content or "").rstrip('\n')
        try:
            original_file.write_text(final_content, encoding='utf-8')
        except Exception as e:
            raise IOError(f"Failed to write final content to {path}: {e}")

        response = {
            "user_action": "COMMITTED",
            "message": f"Dangerous mode active (FS_MCP_FLAG). Changes committed to '{path}' without human review.",
        }
        try:
            if temp_dir.exists():
                shutil.rmtree(temp_dir)
        except Exception as e:
            response["message"] += f" Session cleanup failed: {e}"
        return json.dumps(response, indent=2)

    # --- Step 3: Display, Launch, and Wait for Human ---
    vscode_command = f'code --diff "{current_file_path}" "{future_file_path}"'
    
    print(f"\n--- WAITING FOR HUMAN REVIEW ---\nPlease review the proposed changes in VS Code:\n\n{vscode_command}\n")
    print(f'To approve, save without changes. To request changes, edit the file and save.')
    if IS_VSCODE_CLI_AVAILABLE:
        try:
            subprocess.Popen(vscode_command, shell=True)
            print("✅ Automatically launched VS Code diff view.")
        except Exception as e:
            print(f"⚠️ Failed to launch VS Code automatically: {e}")

    initial_mod_time = future_file_path.stat().st_mtime
    while True:
        await asyncio.sleep(1)
        if future_file_path.stat().st_mtime > initial_mod_time: break    
    # --- Step 3: Interpret User's Action ---
    user_edited_content = future_file_path.read_text(encoding='utf-8')
    response = {"session_path": str(temp_dir)}
    
    # Approval = user saved without making changes (content matches proposal)
    proposal_text = active_proposal_content if active_proposal_content is not None else ""
    is_approved = user_edited_content == proposal_text

    if is_approved:
        # Auto-commit: write to original file and clean up session
        original_file = validate_path(path)
        final_content = user_edited_content.rstrip('\n')
        try:
            original_file.write_text(final_content, encoding='utf-8')
            print(f"✅ Changes committed to {path}. You can safely close the diff view.")
        except Exception as e:
            raise IOError(f"Failed to write final content to {path}: {e}")
        
        # Clean up session directory
        try:
            shutil.rmtree(temp_dir)
        except Exception as e:
            response["user_action"] = "COMMITTED"
            response["message"] = f"Successfully committed changes to '{path}', but failed to clean up session dir: {e}"
            return json.dumps(response, indent=2)
        
        response["user_action"] = "COMMITTED"
        response["message"] = f"Changes committed to '{path}'. No further action needed."
        # Remove session_path from response since session is cleaned up
        del response["session_path"]
    else:
        current_file_path.write_text(user_edited_content, encoding='utf-8')

        user_feedback_diff = "".join(difflib.unified_diff(
            proposal_text.splitlines(keepends=True),
            user_edited_content.splitlines(keepends=True),
            fromfile=f"a/{future_file_path.name} (agent proposal)",
            tofile=f"b/{future_file_path.name} (user feedback)"
        ))
        response["user_action"] = "REVIEW"
        response["message"] = "User provided feedback. A diff is included. Propose a new edit against the updated content."
        response["user_feedback_diff"] = user_feedback_diff
        
    return json.dumps(response, indent=2)