import asyncio
import difflib
import hashlib
import logging
import math
import os
import uuid
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

import openai

from ...clients.apply import ApplyLLMClient, ApplyRequest, ApplyResponse
from ...config.settings import APPLY_SEMANTIC_CHECK, MAX_FILE_SIZE_BYTES
from ...encoding import atomic_write, get_project_encoding, read_text_with_fallback
from ...encoding.exceptions import EncodingDetectionError as BaseEncodingDetectionError
from ...observability import get_trace_id
from ...observability import tool_name as tool_name_ctx
from ...utils import validate_file_path
from . import errors, snippet
from . import logging as apply_logging
from .exceptions import (
    ApiInvalidResponseError,
    ApplyError,
    FileNotWritableError,
    FileTooLargeError,
)

logger = logging.getLogger(__name__)

# Per-file lock map for serializing concurrent edits to the same file
_path_locks: dict[str, asyncio.Lock] = {}
_PATH_LOCKS_MAX = 256


def _get_path_lock(path: str) -> asyncio.Lock:
    if path not in _path_locks:
        # Evict unlocked entries when map exceeds threshold
        if len(_path_locks) >= _PATH_LOCKS_MAX:
            idle = [k for k, v in _path_locks.items() if not v.locked()]
            for k in idle:
                del _path_locks[k]
        _path_locks[path] = asyncio.Lock()
    return _path_locks[path]


@dataclass
class ApplyContext:
    trace_id: str
    started_at: datetime
    file_path: str
    instruction: str | None

    def elapsed_ms(self) -> int:
        return int((datetime.now(UTC) - self.started_at).total_seconds() * 1000)


def _ok_result(
    ctx: ApplyContext,
    path: str,
    message: str,
    diff: str | None = None,
) -> dict[str, Any]:
    return {
        "status": "ok",
        "path": path,
        "trace_id": ctx.trace_id,
        "timing_ms": ctx.elapsed_ms(),
        "diff": diff,
        "message": message,
    }


async def _report_apply_done(
    on_progress: Callable[[int, int, str], Awaitable[None]] | None,
) -> None:
    if on_progress:
        await on_progress(2, 2, "Done")


def _resolve_path(
    file_path: str,
    base_dir: str | None,
    ctx: ApplyContext,
    *,
    extra_paths: Sequence[str] = (),
) -> tuple[Path, bool, int] | dict[str, Any]:
    """Resolve and validate file path, check file status.

    Args:
        file_path: Target file path (absolute or relative).
        base_dir: Base directory restriction. If None, only absolute paths are accepted.
        ctx: Apply context for error reporting.
        extra_paths: Additional allowed directories.

    Returns:
        On success returns (resolved_path, file_exists, file_size),
        on failure returns error dict.
    """
    # When base_dir is None, require absolute paths (no boundary restriction)
    if base_dir is None:
        if not os.path.isabs(file_path):
            return errors.recoverable_error(
                "INVALID_PATH",
                "Relative paths require MCP_BASE_DIR to be set. Use absolute path or set MCP_BASE_DIR.",
                file_path,
                ctx.instruction,
                ctx.trace_id,
                ctx.elapsed_ms(),
            )
        try:
            resolved_path = Path(file_path).resolve()
        except (OSError, ValueError, RuntimeError) as e:
            return errors.recoverable_error(
                "INVALID_PATH", str(e), file_path, ctx.instruction, ctx.trace_id, ctx.elapsed_ms()
            )
    else:
        try:
            resolved_path = validate_file_path(file_path, base_dir, extra_paths=extra_paths)
        except RuntimeError as e:
            return errors.recoverable_error(
                "INVALID_PATH", str(e), file_path, ctx.instruction, ctx.trace_id, ctx.elapsed_ms()
            )

    file_exists = resolved_path.exists()
    if file_exists and not resolved_path.is_file():
        return errors.recoverable_error(
            "INVALID_PATH",
            f"Path exists but is not a file: {resolved_path}",
            file_path,
            ctx.instruction,
            ctx.trace_id,
            ctx.elapsed_ms(),
        )
    file_size = resolved_path.stat().st_size if file_exists else 0
    return resolved_path, file_exists, file_size


async def _create_new_file(
    ctx: ApplyContext, resolved_path: Path, edit_snippet: str
) -> dict[str, Any]:
    if snippet.contains_truncation_markers(edit_snippet):
        return errors.recoverable_error(
            "INVALID_INPUT",
            "New file creation does not support truncation markers. "
            "Provide the complete file content without '// ... existing code ...' or '# ... existing code ...'.",
            ctx.file_path,
            ctx.instruction,
            ctx.trace_id,
            ctx.elapsed_ms(),
        )

    async with _get_path_lock(str(resolved_path)):
        if resolved_path.exists():
            return errors.recoverable_error(
                "FILE_EXISTS",
                f"File already exists (created by concurrent operation): {ctx.file_path}",
                ctx.file_path,
                ctx.instruction,
                ctx.trace_id,
                ctx.elapsed_ms(),
            )
        resolved_path.parent.mkdir(parents=True, exist_ok=True)
        encoding = get_project_encoding() or "utf-8"
        atomic_write(resolved_path, edit_snippet, encoding=encoding)

    apply_logging.log_create_success(ctx.trace_id, resolved_path, edit_snippet, ctx.instruction)
    logger.debug("[%s] Created new file %s", ctx.trace_id, resolved_path)

    return _ok_result(
        ctx,
        str(resolved_path),
        f"Created new file ({resolved_path.stat().st_size} bytes)",
        diff=None,
    )


async def _apply_to_existing_file(
    ctx: ApplyContext,
    backend: ApplyLLMClient,
    resolved_path: Path,
    edit_snippet: str,
    file_size: int,
    on_progress: Callable[[int, int, str], Awaitable[None]] | None = None,
) -> dict[str, Any]:
    concrete = snippet.concrete_lines(edit_snippet)
    has_markers = snippet.contains_truncation_markers(edit_snippet)
    has_explicit_remove = bool(snippet.extract_remove_targets(edit_snippet))
    if not concrete:
        # Try to provide symbol hints even for empty-concrete path
        try:
            hint_code, _ = read_text_with_fallback(resolved_path)
            symbols = snippet.extract_top_level_symbols(hint_code, str(resolved_path))
            hint = ""
            if symbols:
                sym_preview = symbols[:10]
                hint = f" The file defines: {', '.join(sym_preview)}."
        except Exception:
            hint = ""
        return errors.recoverable_error(
            "NEEDS_MORE_CONTEXT",
            f"edit_snippet contains only placeholders, no concrete code found.{hint}"
            " Include 1-3 lines of existing code as anchors for positioning.",
            ctx.file_path,
            ctx.instruction,
            ctx.trace_id,
            ctx.elapsed_ms(),
        )

    if file_size > MAX_FILE_SIZE_BYTES:
        raise FileTooLargeError(file_size, MAX_FILE_SIZE_BYTES)

    # Pre-check file and directory writability BEFORE calling API (avoid wasting API calls)
    if not os.access(resolved_path, os.W_OK):
        raise FileNotWritableError(ctx.file_path)
    if not os.access(resolved_path.parent, os.W_OK):
        raise FileNotWritableError(f"Directory not writable: {resolved_path.parent}")

    async with _get_path_lock(str(resolved_path)):
        initial_code, detected_encoding = read_text_with_fallback(resolved_path)
        initial_hash = hashlib.sha256(resolved_path.read_bytes()).hexdigest()

        file_lines = initial_code.count("\n") + 1

        anchor_passed, _ = snippet.anchor_precheck(concrete, initial_code)
        if not anchor_passed:
            symbols = snippet.extract_top_level_symbols(initial_code, str(resolved_path))
            hint = ""
            if symbols:
                sym_preview = symbols[:10]
                hint = (
                    f" The file defines: {', '.join(sym_preview)}."
                    " Include 1-2 lines near your target as anchors."
                )
            return errors.recoverable_error(
                "NEEDS_MORE_CONTEXT",
                f"Anchor lines in edit_snippet cannot be located in the file ({file_lines} lines).{hint}",
                ctx.file_path,
                ctx.instruction,
                ctx.trace_id,
                ctx.elapsed_ms(),
                file_lines=file_lines,
            )

        if on_progress:
            await on_progress(1, 2, "Merging")

        metadata = {
            "source": "fastmcp",
            "tool": "fast_apply",
            "file_path": str(resolved_path),
            "trace_id": ctx.trace_id,
        }

        request = ApplyRequest(
            initial_code=initial_code,
            edit_snippet=edit_snippet,
            instruction=ctx.instruction,
            metadata=metadata,
        )
        response: ApplyResponse = await backend.apply(request)

        merged_code = response.merged_code
        usage = response.usage

        if not isinstance(merged_code, str):
            raise ApiInvalidResponseError()

        diff = "".join(
            difflib.unified_diff(
                initial_code.splitlines(keepends=True),
                merged_code.splitlines(keepends=True),
                fromfile="before",
                tofile="after",
            )
        )
        added_lines, deleted_lines = snippet.count_nonempty_diff_lines(diff)
        lines_touched = max(added_lines, deleted_lines)
        deletion_dominant_diff = deleted_lines > added_lines

        original_chars = len(initial_code)
        original_lines = file_lines if initial_code else 0
        merged_chars = len(merged_code)
        merged_lines = merged_code.count("\n") + 1 if merged_code else 0

        if has_markers:
            initial_had_markers = snippet.contains_truncation_markers(initial_code)
            merged_has_markers = snippet.contains_truncation_markers(merged_code)
            if merged_has_markers and not initial_had_markers:
                file_lines = initial_code.count("\n") + 1
                return errors.recoverable_error(
                    "MARKER_LEAKAGE",
                    "Detected truncation marker text in merged output. "
                    "This usually means the merge model treated markers as literal text instead of expanding them. "
                    "Simplify edit_snippet and add more unique anchor lines.",
                    ctx.file_path,
                    ctx.instruction,
                    ctx.trace_id,
                    ctx.elapsed_ms(),
                    file_lines=file_lines,
                )

        if original_chars > 0 and original_lines > 0:
            char_loss = max(0.0, (original_chars - merged_chars) / original_chars)
            line_loss = max(0.0, (original_lines - merged_lines) / original_lines)
            if char_loss > 0.6 and line_loss > 0.5:
                if has_explicit_remove:
                    logger.debug(
                        "[%s] EXPLICIT_DELETE_INTENT for %s: skipping TRUNCATION_DETECTED (remove directives present)",
                        ctx.trace_id,
                        resolved_path,
                    )
                else:
                    return errors.recoverable_error(
                        "TRUNCATION_DETECTED",
                        f"Catastrophic truncation detected (charLoss={int(char_loss * 100)}%, "
                        f"lineLoss={int(line_loss * 100)}%).",
                        ctx.file_path,
                        ctx.instruction,
                        ctx.trace_id,
                        ctx.elapsed_ms(),
                        file_lines=file_lines,
                    )

        if not diff:
            if snippet.expects_changes(edit_snippet, initial_code):
                logger.warning(
                    "[%s] APPLY_NOOP: Expected changes but got no diff for %s",
                    ctx.trace_id,
                    resolved_path,
                )
                file_lines = initial_code.count("\n") + 1
                return errors.recoverable_error(
                    "APPLY_NOOP",
                    f"Merged result is identical to original file ({file_lines} lines). "
                    "The edit may lack sufficient context for the merge model to locate the target. "
                    "Add 1-3 unique anchor lines from near the edit target.",
                    ctx.file_path,
                    ctx.instruction,
                    ctx.trace_id,
                    ctx.elapsed_ms(),
                    file_lines=file_lines,
                )

            logger.debug("[%s] No changes needed (idempotent) for %s", ctx.trace_id, resolved_path)
            await _report_apply_done(on_progress)
            return _ok_result(
                ctx,
                str(resolved_path),
                "No changes needed (already matches)",
                diff=None,
            )

        # L1 Syntax validation (always enabled for Python files)
        syntax_passed, syntax_reason = snippet.validate_syntax_delta(
            initial_code, merged_code, str(resolved_path)
        )
        if not syntax_passed:
            logger.warning(
                "[%s] SYNTAX_CHECK_FAILED for %s: %s",
                ctx.trace_id,
                resolved_path,
                syntax_reason,
            )
            file_lines = initial_code.count("\n") + 1
            return errors.recoverable_error(
                "SYNTAX_CHECK_FAILED",
                f"Merged code has syntax error: {syntax_reason}",
                ctx.file_path,
                ctx.instruction,
                ctx.trace_id,
                ctx.elapsed_ms(),
                file_lines=file_lines,
            )

        # Blast-radius guard: reject diffs that rewrite most of the file.
        blast_radius_limit = max(1, math.ceil(file_lines * 0.8))
        if lines_touched > blast_radius_limit:
            if has_explicit_remove and deletion_dominant_diff:
                logger.debug(
                    "[%s] EXPLICIT_DELETE_INTENT for %s: skipping BLAST_RADIUS_EXCEEDED (remove directives present)",
                    ctx.trace_id,
                    resolved_path,
                )
            else:
                logger.warning(
                    "[%s] BLAST_RADIUS_EXCEEDED for %s: %d lines touched, file=%d lines, limit=%d",
                    ctx.trace_id,
                    resolved_path,
                    lines_touched,
                    file_lines,
                    blast_radius_limit,
                )
                return errors.recoverable_error(
                    "BLAST_RADIUS_EXCEEDED",
                    f"Diff touches {lines_touched} lines but file only has {file_lines} lines "
                    f"(limit={blast_radius_limit}, 80% of file). "
                    "This looks like a full-file rewrite. Split into smaller edits.",
                    ctx.file_path,
                    ctx.instruction,
                    ctx.trace_id,
                    ctx.elapsed_ms(),
                    file_lines=file_lines,
                )

        # Symbol preservation guard: reject if top-level symbols unexpectedly disappeared
        sym_passed, sym_reason = snippet.check_symbol_preservation(
            initial_code, merged_code, edit_snippet, str(resolved_path)
        )
        if sym_passed and sym_reason:
            logger.debug(
                "[%s] SYMBOL_CHANGE_DETECTED for %s: %s", ctx.trace_id, resolved_path, sym_reason
            )
        if not sym_passed:
            logger.warning(
                "[%s] SYMBOL_LOST for %s: %s",
                ctx.trace_id,
                resolved_path,
                sym_reason,
            )
            file_lines = initial_code.count("\n") + 1
            return errors.recoverable_error(
                "SYMBOL_LOST",
                f"Merge would remove symbols not targeted by edit: {sym_reason}",
                ctx.file_path,
                ctx.instruction,
                ctx.trace_id,
                ctx.elapsed_ms(),
                file_lines=file_lines,
            )

        # Semantic check is opt-in because context-only intent checks can add false positives.
        if APPLY_SEMANTIC_CHECK:
            post_check_passed, post_check_reason = snippet.post_check_merged_code(
                edit_snippet, merged_code, initial_code
            )
            if not post_check_passed:
                logger.warning(
                    "[%s] SEMANTIC_CHECK_FAILED for %s: %s",
                    ctx.trace_id,
                    resolved_path,
                    post_check_reason,
                )
                return errors.recoverable_error(
                    "SEMANTIC_CHECK_FAILED",
                    f"Merged code does not match expected changes: {post_check_reason}",
                    ctx.file_path,
                    ctx.instruction,
                    ctx.trace_id,
                    ctx.elapsed_ms(),
                )

        # Optimistic concurrency: verify file unchanged since read
        current_hash = hashlib.sha256(resolved_path.read_bytes()).hexdigest()
        if current_hash != initial_hash:
            logger.warning(
                "[%s] CONTENT_CONFLICT for %s: file changed during apply",
                ctx.trace_id,
                resolved_path,
            )
            file_lines = initial_code.count("\n") + 1
            return errors.recoverable_error(
                "CONTENT_CONFLICT",
                "File was modified by another process during apply. Please retry.",
                ctx.file_path,
                ctx.instruction,
                ctx.trace_id,
                ctx.elapsed_ms(),
                file_lines=file_lines,
            )

        atomic_write(resolved_path, merged_code, encoding=detected_encoding)

        apply_logging.log_apply_success(
            ctx.trace_id,
            ctx.started_at,
            resolved_path,
            file_size,
            edit_snippet,
            ctx.instruction,
            usage,
        )
        logger.debug(
            "[%s] Applied edit to %s (latency=%dms)",
            ctx.trace_id,
            resolved_path,
            ctx.elapsed_ms(),
        )

        await _report_apply_done(on_progress)
        return _ok_result(
            ctx,
            str(resolved_path),
            "Applied code changes successfully.",
            diff=diff,
        )


async def apply_file_logic(
    backend: ApplyLLMClient,
    file_path: str,
    edit_snippet: str,
    instruction: str | None,
    base_dir: str | None,
    *,
    extra_paths: Sequence[str] = (),
    on_progress: Callable[[int, int, str], Awaitable[None]] | None = None,
) -> dict[str, Any]:
    """Core logic for fast_apply (testable independently).

    Args:
        backend: Apply backend instance.
        file_path: Target file path.
        edit_snippet: Code snippet to apply, using abbreviation comments.
        instruction: Optional natural language instruction forwarded to the apply backend for disambiguation.
        base_dir: Base directory restriction. If None, only absolute paths are accepted.
        extra_paths: Additional allowed directories.

    Returns:
        A structured dict with status, path, trace_id, timing_ms, diff, and message.
    """
    trace_id = get_trace_id() if tool_name_ctx.get() else str(uuid.uuid4())[:8]
    ctx = ApplyContext(
        trace_id=trace_id,
        started_at=datetime.now(UTC),
        file_path=file_path,
        instruction=instruction,
    )

    edit_snippet = snippet.normalize_edit_snippet(edit_snippet, file_path)

    if not edit_snippet or not edit_snippet.strip():
        empty_result = errors.recoverable_error(
            "INVALID_INPUT",
            "edit_snippet cannot be empty",
            file_path,
            instruction,
            ctx.trace_id,
            ctx.elapsed_ms(),
        )
        apply_logging.log_apply_recoverable_error(
            ctx.trace_id,
            ctx.started_at,
            file_path,
            edit_snippet,
            instruction,
            error_code=str(empty_result.get("code") or "") or None,
            message=str(empty_result.get("message") or ""),
        )
        return empty_result

    logged_error_event = False
    result: dict[str, Any]

    try:
        resolved = _resolve_path(file_path, base_dir, ctx, extra_paths=extra_paths)
        if isinstance(resolved, dict):
            result = resolved
        else:
            resolved_path, file_exists, file_size = resolved

            if not file_exists:
                result = await _create_new_file(ctx, resolved_path, edit_snippet)
            else:
                result = await _apply_to_existing_file(
                    ctx,
                    backend,
                    resolved_path,
                    edit_snippet,
                    file_size,
                    on_progress=on_progress,
                )
    except Exception as exc:
        apply_logging.log_apply_error(
            ctx.trace_id, ctx.started_at, file_path, edit_snippet, instruction, exc
        )
        logged_error_event = True

        if isinstance(exc, openai.APIError):
            logger.warning(
                "[%s] Apply API error for %s: %s",
                ctx.trace_id,
                file_path,
                exc,
            )
            return errors.openai_error_to_recoverable(
                exc, file_path, instruction, ctx.trace_id, ctx.elapsed_ms()
            )

        if isinstance(exc, ValueError):
            logger.warning(
                "[%s] API response parsing error for %s: %s",
                ctx.trace_id,
                file_path,
                exc,
            )
            return errors.recoverable_error(
                "API_INVALID_RESPONSE",
                str(exc),
                file_path,
                instruction,
                ctx.trace_id,
                ctx.elapsed_ms(),
            )

        if isinstance(exc, (ApplyError, BaseEncodingDetectionError)):
            error_code = getattr(exc, "error_code", "ENCODING_ERROR")
            message = getattr(exc, "message", str(exc))
            logger.warning(
                "[%s] Apply error (%s) for %s: %s",
                ctx.trace_id,
                error_code,
                file_path,
                message,
            )
            return errors.recoverable_error(
                error_code, message, file_path, instruction, ctx.trace_id, ctx.elapsed_ms()
            )

        if isinstance(exc, PermissionError):
            logger.warning("[%s] Permission error for %s: %s", ctx.trace_id, file_path, exc)
            return errors.recoverable_error(
                "PERMISSION_ERROR",
                f"Permission denied: {exc}",
                file_path,
                instruction,
                ctx.trace_id,
                ctx.elapsed_ms(),
            )

        if isinstance(exc, OSError):
            errno_info = f"errno={exc.errno}" if exc.errno else ""
            strerror = exc.strerror or str(exc)
            logger.warning("[%s] Filesystem error for %s: %s", ctx.trace_id, file_path, exc)
            return errors.recoverable_error(
                "FS_ERROR",
                f"Filesystem error ({type(exc).__name__}, {errno_info}): {strerror}",
                file_path,
                instruction,
                ctx.trace_id,
                ctx.elapsed_ms(),
            )

        logger.error("[%s] Apply failed for %s: %s", ctx.trace_id, file_path, exc)
        result = errors.recoverable_error(
            "INTERNAL_ERROR",
            f"Unexpected error ({type(exc).__name__}): {exc}",
            file_path,
            instruction,
            ctx.trace_id,
            ctx.elapsed_ms(),
        )

    if result.get("status") == "error" and not logged_error_event:
        apply_logging.log_apply_recoverable_error(
            ctx.trace_id,
            ctx.started_at,
            file_path,
            edit_snippet,
            instruction,
            error_code=str(result.get("code") or "") or None,
            message=str(result.get("message") or result.get("error") or ""),
        )

    return result
