"""Skill-level file operation tools for CleverAgents.

Provides enhanced file tools that integrate with the skill framework:

- **ReadFile**: Read file contents with binary detection, encoding
  detection, content size limits, and UTF-8 normalization.
- **WriteFile**: Write file contents with atomic writes (temp file
  then rename), path traversal guards, and newline normalization.
- **EditFile**: Edit file contents with find/replace or line-range
  edits, atomic operations, and permission preservation.
- **DeleteFile**: Delete files with existence checks and path
  traversal guards.

## Path Traversal Guards

All tools reject paths containing ``..`` segments or absolute paths
that escape the sandbox root directory.  The rejected path is included
in the error message for diagnostics.

## Registration

```python
from cleveragents.skills.builtins.file_ops import register_skill_file_tools
from cleveragents.tool.registry import ToolRegistry

registry = ToolRegistry()
register_skill_file_tools(registry)
```
"""

from __future__ import annotations

import os
import stat
import tempfile
from pathlib import Path
from typing import Any

from cleveragents.domain.models.core.tool import ToolCapability
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.runtime import ToolSpec

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

#: Maximum content size in bytes for ReadFile (10 MB).
MAX_READ_SIZE: int = 10 * 1024 * 1024

#: Byte prefixes that indicate a binary file.
_BINARY_SIGNATURES: list[bytes] = [
    b"\x00",
    b"\x89PNG",
    b"GIF8",
    b"\xff\xd8\xff",
    b"PK\x03\x04",
    b"\x7fELF",
    b"MZ",
    b"\x1f\x8b",
]

# ---------------------------------------------------------------------------
# Path validation
# ---------------------------------------------------------------------------


def validate_sandbox_path(path_str: str, sandbox_root: str | None = None) -> Path:
    """Validate that *path_str* does not escape the sandbox.

    Raises ``ValueError`` with the rejected path when traversal is
    detected or the resolved path falls outside the sandbox root.
    """
    if not path_str:
        raise ValueError("Path must not be empty")

    root = Path(sandbox_root) if sandbox_root else Path.cwd()
    root = root.resolve()

    target = (root / path_str).resolve()
    if not str(target).startswith(str(root)):
        raise ValueError(f"Path traversal detected: '{path_str}' escapes sandbox root")
    return target


# ---------------------------------------------------------------------------
# Binary detection
# ---------------------------------------------------------------------------


def _is_binary(path: Path, sample_size: int = 8192) -> bool:
    """Return ``True`` if *path* appears to contain binary content."""
    try:
        chunk = path.read_bytes()[:sample_size]
    except OSError:
        return False

    for sig in _BINARY_SIGNATURES:
        if chunk.startswith(sig):
            return True

    # Heuristic: if more than 10% of bytes are non-text control chars
    # (excluding common whitespace), treat as binary.
    control_count = sum(
        1 for b in chunk if b < 0x08 or (0x0E <= b <= 0x1F and b != 0x1B)
    )
    return bool(chunk and control_count / len(chunk) > 0.10)


# ---------------------------------------------------------------------------
# Newline normalization
# ---------------------------------------------------------------------------


def _normalize_newlines(content: str, mode: str = "preserve") -> str:
    """Normalize line endings in *content*.

    Modes:
        - ``"preserve"``: return content unchanged (default)
        - ``"lf"``: convert all line endings to ``\\n``
        - ``"crlf"``: convert all line endings to ``\\r\\n``
    """
    if mode == "preserve":
        return content
    if mode == "lf":
        return content.replace("\r\n", "\n").replace("\r", "\n")
    if mode == "crlf":
        normalized = content.replace("\r\n", "\n").replace("\r", "\n")
        return normalized.replace("\n", "\r\n")
    raise ValueError(
        f"Invalid newline mode '{mode}'. Must be 'preserve', 'lf', or 'crlf'"
    )


# ---------------------------------------------------------------------------
# Handlers
# ---------------------------------------------------------------------------


def _handle_skill_read_file(inputs: dict[str, Any]) -> dict[str, Any]:
    """Read file with binary detection, size limits, and UTF-8 normalization."""
    path = validate_sandbox_path(inputs["path"], inputs.get("sandbox_root"))
    encoding: str = inputs.get("encoding", "utf-8")
    offset: int = inputs.get("offset", 0)
    limit: int | None = inputs.get("limit")

    if not path.exists():
        raise FileNotFoundError(f"File not found: {inputs['path']}")

    if not path.is_file():
        raise ValueError(f"Not a regular file: {inputs['path']}")

    # Binary detection
    if _is_binary(path):
        raise ValueError(
            f"Binary file detected: '{inputs['path']}'. "
            "ReadFile only supports text files."
        )

    # Size limit check
    file_size = path.stat().st_size
    if file_size > MAX_READ_SIZE:
        raise ValueError(
            f"File too large: {file_size} bytes exceeds limit of {MAX_READ_SIZE} bytes"
        )

    raw_bytes = path.read_bytes()
    # UTF-8 normalization: decode with the requested encoding, then
    # ensure the result is valid UTF-8
    content = raw_bytes.decode(encoding, errors="replace")

    lines = content.splitlines(keepends=True)
    if offset > 0 or limit is not None:
        end = offset + limit if limit is not None else len(lines)
        lines = lines[offset:end]
        content = "".join(lines)

    return {
        "content": content,
        "size": file_size,
        "encoding": encoding,
        "line_count": len(content.splitlines()),
    }


def _handle_skill_write_file(inputs: dict[str, Any]) -> dict[str, Any]:
    """Write file atomically with newline normalization and path guards."""
    path = validate_sandbox_path(inputs["path"], inputs.get("sandbox_root"))
    content: str = inputs["content"]
    encoding: str = inputs.get("encoding", "utf-8")
    create_dirs: bool = inputs.get("create_dirs", True)
    newline_mode: str = inputs.get("newline_mode", "preserve")

    content = _normalize_newlines(content, newline_mode)

    created = not path.exists()
    if create_dirs:
        path.parent.mkdir(parents=True, exist_ok=True)

    # Atomic write: write to temp file then rename
    fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
    try:
        os.write(fd, content.encode(encoding))
        os.close(fd)
        os.replace(tmp_path, str(path))
    except BaseException:
        os.close(fd) if not _is_fd_closed(fd) else None
        if os.path.exists(tmp_path):
            os.unlink(tmp_path)
        raise

    return {
        "path": str(path),
        "size": path.stat().st_size,
        "created": created,
    }


def _is_fd_closed(fd: int) -> bool:
    """Check if a file descriptor is already closed."""
    try:
        os.fstat(fd)
        return False
    except OSError:
        return True


def _handle_skill_edit_file(inputs: dict[str, Any]) -> dict[str, Any]:
    """Edit file with find/replace, atomic write, permission preservation."""
    path = validate_sandbox_path(inputs["path"], inputs.get("sandbox_root"))
    old_text: str = inputs["old_text"]
    new_text: str = inputs["new_text"]
    replace_all: bool = inputs.get("replace_all", False)
    newline_mode: str = inputs.get("newline_mode", "preserve")

    if not path.exists():
        raise FileNotFoundError(f"File not found: {inputs['path']}")

    # Preserve original permissions
    original_mode = path.stat().st_mode

    content = path.read_text()
    count = content.count(old_text)

    if count == 0:
        raise ValueError(f"old_text not found in {inputs['path']}")

    if replace_all:
        content = content.replace(old_text, new_text)
    else:
        content = content.replace(old_text, new_text, 1)
        count = 1

    content = _normalize_newlines(content, newline_mode)

    # Atomic write preserving permissions
    fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
    try:
        os.write(fd, content.encode("utf-8"))
        os.close(fd)
        os.chmod(tmp_path, stat.S_IMODE(original_mode))
        os.replace(tmp_path, str(path))
    except BaseException:
        os.close(fd) if not _is_fd_closed(fd) else None
        if os.path.exists(tmp_path):
            os.unlink(tmp_path)
        raise

    return {
        "path": str(path),
        "replacements": count,
    }


def _handle_skill_delete_file(inputs: dict[str, Any]) -> dict[str, Any]:
    """Delete a file with existence check and path guards."""
    path = validate_sandbox_path(inputs["path"], inputs.get("sandbox_root"))
    existed = path.exists()
    if existed:
        if not path.is_file():
            raise ValueError(f"Cannot delete non-file: '{inputs['path']}'")
        path.unlink()
    return {
        "path": str(path),
        "existed": existed,
    }


# ---------------------------------------------------------------------------
# Tool specs
# ---------------------------------------------------------------------------

SKILL_FILE_READ_SPEC = ToolSpec(
    name="skill/file-read",
    description=(
        "Read file content with binary detection, size limits, and UTF-8 normalization"
    ),
    input_schema={
        "type": "object",
        "properties": {
            "path": {"type": "string", "description": "File path to read"},
            "encoding": {
                "type": "string",
                "default": "utf-8",
                "description": "File encoding",
            },
            "offset": {
                "type": "integer",
                "default": 0,
                "description": "Line offset to start reading from",
            },
            "limit": {
                "type": "integer",
                "description": "Maximum number of lines to read",
            },
            "sandbox_root": {
                "type": "string",
                "description": "Sandbox root directory",
            },
        },
        "required": ["path"],
    },
    output_schema={
        "type": "object",
        "properties": {
            "content": {"type": "string"},
            "size": {"type": "integer"},
            "encoding": {"type": "string"},
            "line_count": {"type": "integer"},
        },
    },
    capabilities=ToolCapability(read_only=True),
    handler=_handle_skill_read_file,
)

SKILL_FILE_WRITE_SPEC = ToolSpec(
    name="skill/file-write",
    description=("Write content to file atomically with newline normalization"),
    input_schema={
        "type": "object",
        "properties": {
            "path": {"type": "string", "description": "File path to write"},
            "content": {"type": "string", "description": "Content to write"},
            "encoding": {
                "type": "string",
                "default": "utf-8",
                "description": "File encoding",
            },
            "create_dirs": {
                "type": "boolean",
                "default": True,
                "description": "Create parent directories if needed",
            },
            "newline_mode": {
                "type": "string",
                "default": "preserve",
                "description": "Newline mode: preserve, lf, or crlf",
            },
            "sandbox_root": {
                "type": "string",
                "description": "Sandbox root directory",
            },
        },
        "required": ["path", "content"],
    },
    output_schema={
        "type": "object",
        "properties": {
            "path": {"type": "string"},
            "size": {"type": "integer"},
            "created": {"type": "boolean"},
        },
    },
    capabilities=ToolCapability(writes=True, write_scope="file"),
    handler=_handle_skill_write_file,
)

SKILL_FILE_EDIT_SPEC = ToolSpec(
    name="skill/file-edit",
    description=(
        "Edit file with string replacement, atomic write, and permission preservation"
    ),
    input_schema={
        "type": "object",
        "properties": {
            "path": {"type": "string", "description": "File path to edit"},
            "old_text": {"type": "string", "description": "Text to find"},
            "new_text": {
                "type": "string",
                "description": "Replacement text",
            },
            "replace_all": {
                "type": "boolean",
                "default": False,
                "description": "Replace all occurrences",
            },
            "newline_mode": {
                "type": "string",
                "default": "preserve",
                "description": "Newline mode: preserve, lf, or crlf",
            },
            "sandbox_root": {
                "type": "string",
                "description": "Sandbox root directory",
            },
        },
        "required": ["path", "old_text", "new_text"],
    },
    output_schema={
        "type": "object",
        "properties": {
            "path": {"type": "string"},
            "replacements": {"type": "integer"},
        },
    },
    capabilities=ToolCapability(writes=True, write_scope="file"),
    handler=_handle_skill_edit_file,
)

SKILL_FILE_DELETE_SPEC = ToolSpec(
    name="skill/file-delete",
    description="Delete a file with existence check and path traversal guards",
    input_schema={
        "type": "object",
        "properties": {
            "path": {
                "type": "string",
                "description": "File path to delete",
            },
            "sandbox_root": {
                "type": "string",
                "description": "Sandbox root directory",
            },
        },
        "required": ["path"],
    },
    output_schema={
        "type": "object",
        "properties": {
            "path": {"type": "string"},
            "existed": {"type": "boolean"},
        },
    },
    capabilities=ToolCapability(writes=True, write_scope="file"),
    handler=_handle_skill_delete_file,
)

ALL_SKILL_FILE_TOOLS: list[ToolSpec] = [
    SKILL_FILE_READ_SPEC,
    SKILL_FILE_WRITE_SPEC,
    SKILL_FILE_EDIT_SPEC,
    SKILL_FILE_DELETE_SPEC,
]


def register_skill_file_tools(registry: ToolRegistry) -> None:
    """Register all skill-level file tools into *registry*."""
    for spec in ALL_SKILL_FILE_TOOLS:
        registry.register(spec)
