"""Built-in workspace tools for files and commands."""

from __future__ import annotations

import asyncio
from dataclasses import dataclass
from pathlib import Path
import re
import shlex

from pydantic import BaseModel, Field

from yier_agents.src.tool import Tool, ToolContext, ToolOutput

FORBIDDEN_SHELL_TOKENS = ("&&", "||", "|", ";", ">", "<", "$(", "`")


@dataclass(slots=True)
class WorkspaceAccess:
    """Restrict file and command tools to approved roots."""

    allowed_roots: tuple[Path, ...] = ()

    def resolve_file(self, raw_path: str) -> Path:
        path = Path(raw_path).expanduser()
        if not path.is_absolute():
            path = (Path.cwd() / path).resolve()
        else:
            path = path.resolve()

        if self.allowed_roots and not self.is_allowed(path):
            allowed = ", ".join(str(root) for root in self.allowed_roots)
            raise PermissionError(f"Path is outside allowed roots: {path}. Allowed roots: {allowed}")

        return path

    def is_allowed(self, path: Path) -> bool:
        for root in self.allowed_roots:
            try:
                path.relative_to(root)
                return True
            except ValueError:
                continue
        return False


class ReadFileParams(BaseModel):
    """Parameters for the read_file tool."""

    path: str = Field(description="The file path to read.")
    start_line: int | None = Field(default=None, ge=1, description="Optional 1-based start line.")
    end_line: int | None = Field(default=None, ge=1, description="Optional 1-based end line.")
    max_chars: int = Field(default=6000, ge=1, le=20000, description="Maximum characters to return.")


class ListFilesParams(BaseModel):
    """Parameters for the list_files tool."""

    path: str = Field(default=".", description="Directory path to list.")
    recursive: bool = Field(default=False, description="Whether to list files recursively.")
    include_hidden: bool = Field(default=False, description="Whether to include hidden files and directories.")
    max_entries: int = Field(default=200, ge=1, le=1000, description="Maximum number of entries to return.")


class RunCommandParams(BaseModel):
    """Parameters for the run_command tool."""

    command: str = Field(description="Command to execute without shell operators.")
    cwd: str | None = Field(default=None, description="Optional working directory for the command.")
    timeout_seconds: int = Field(default=30, ge=1, le=300, description="How long to wait before timing out.")
    max_output_chars: int = Field(default=8000, ge=1, le=20000, description="Maximum output characters to return.")


class WriteFileParams(BaseModel):
    """Parameters for the write_file tool."""

    path: str = Field(description="The file path to write.")
    content: str = Field(description="The complete file content to write.")
    append: bool = Field(default=False, description="Whether to append instead of overwrite.")
    create_directories: bool = Field(
        default=True,
        description="Whether to create parent directories automatically.",
    )


class SearchFilesParams(BaseModel):
    """Parameters for the search_files tool."""

    pattern: str = Field(description="Text or regex pattern to search for.")
    path: str = Field(default=".", description="Directory or file path to search.")
    regex: bool = Field(default=False, description="Whether pattern should be treated as a regex.")
    case_sensitive: bool = Field(default=False, description="Whether the search should be case-sensitive.")
    include_hidden: bool = Field(default=False, description="Whether to include hidden files and directories.")
    max_results: int = Field(default=100, ge=1, le=1000, description="Maximum matches to return.")


class ReplaceInFileParams(BaseModel):
    """Parameters for the replace_in_file tool."""

    path: str = Field(description="The file path to modify.")
    old_text: str = Field(description="The exact text to replace.")
    new_text: str = Field(description="The replacement text.")
    replace_all: bool = Field(default=False, description="Whether to replace every occurrence.")


def create_read_file_tool(allowed_roots: list[Path] | None = None) -> Tool[ReadFileParams]:
    access = WorkspaceAccess(_normalize_allowed_roots(allowed_roots))

    async def execute(params: ReadFileParams, ctx: ToolContext) -> ToolOutput:
        file_path = access.resolve_file(params.path)

        if not file_path.exists():
            raise FileNotFoundError(f"File not found: {file_path}")

        if not file_path.is_file():
            raise IsADirectoryError(f"Path is not a file: {file_path}")

        content = file_path.read_text(encoding="utf-8", errors="replace")
        lines = content.splitlines()

        start_line = params.start_line or 1
        end_line = params.end_line or len(lines)

        if end_line < start_line:
            raise ValueError("end_line must be greater than or equal to start_line")

        selected_lines = lines[start_line - 1 : end_line]
        numbered_lines = [
            f"{line_number}: {line}"
            for line_number, line in enumerate(selected_lines, start=start_line)
        ]
        result = "\n".join(numbered_lines)

        truncated = False
        if len(result) > params.max_chars:
            result = f"{result[:params.max_chars]}\n... [truncated]"
            truncated = True

        return ToolOutput(
            content=f"File: {file_path}\n{result}",
            metadata={
                "path": str(file_path),
                "start_line": start_line,
                "end_line": min(end_line, len(lines)),
                "truncated": truncated,
            },
        )

    return Tool(
        name="read_file",
        description=READ_FILE_DESCRIPTION,
        parameters=ReadFileParams,
        execute=execute,
    )


def create_list_files_tool(allowed_roots: list[Path] | None = None) -> Tool[ListFilesParams]:
    access = WorkspaceAccess(_normalize_allowed_roots(allowed_roots))

    async def execute(params: ListFilesParams, ctx: ToolContext) -> ToolOutput:
        directory = access.resolve_file(params.path)

        if not directory.exists():
            raise FileNotFoundError(f"Directory not found: {directory}")

        if not directory.is_dir():
            raise NotADirectoryError(f"Path is not a directory: {directory}")

        iterator = directory.rglob("*") if params.recursive else directory.iterdir()
        entries: list[str] = []

        for item in sorted(iterator):
            relative = item.relative_to(directory)
            if not params.include_hidden and _is_hidden(relative):
                continue

            prefix = "[dir]" if item.is_dir() else "[file]"
            entries.append(f"{prefix} {relative}")
            if len(entries) >= params.max_entries:
                break

        if not entries:
            content = f"Directory: {directory}\nNo entries found."
        else:
            content = f"Directory: {directory}\n" + "\n".join(entries)

        return ToolOutput(
            content=content,
            metadata={
                "path": str(directory),
                "recursive": params.recursive,
                "count": len(entries),
            },
        )

    return Tool(
        name="list_files",
        description=LIST_FILES_DESCRIPTION,
        parameters=ListFilesParams,
        execute=execute,
    )


def create_run_command_tool(allowed_roots: list[Path] | None = None) -> Tool[RunCommandParams]:
    access = WorkspaceAccess(_normalize_allowed_roots(allowed_roots))

    async def execute(params: RunCommandParams, ctx: ToolContext) -> ToolOutput:
        _validate_command(params.command)

        working_directory = access.resolve_file(params.cwd or str(Path.cwd()))
        if not working_directory.exists():
            raise FileNotFoundError(f"Working directory not found: {working_directory}")

        if not working_directory.is_dir():
            raise NotADirectoryError(f"Working directory is not a directory: {working_directory}")

        argv = shlex.split(params.command)
        if not argv:
            raise ValueError("Command must not be empty")

        process = await asyncio.create_subprocess_exec(
            *argv,
            cwd=str(working_directory),
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )

        timed_out = False
        try:
            stdout, stderr = await asyncio.wait_for(
                process.communicate(),
                timeout=params.timeout_seconds,
            )
        except TimeoutError:
            timed_out = True
            process.kill()
            stdout, stderr = await process.communicate()

        stdout_text = stdout.decode("utf-8", errors="replace")
        stderr_text = stderr.decode("utf-8", errors="replace")
        exit_code = process.returncode if process.returncode is not None else -1

        sections = [
            f"Command: {params.command}",
            f"Working directory: {working_directory}",
            f"Exit code: {exit_code}",
        ]

        if timed_out:
            sections.append(f"Timed out after {params.timeout_seconds} seconds")

        if stdout_text:
            sections.extend(["", "[stdout]", stdout_text])

        if stderr_text:
            sections.extend(["", "[stderr]", stderr_text])

        content = "\n".join(sections)
        truncated = False
        if len(content) > params.max_output_chars:
            content = f"{content[:params.max_output_chars]}\n... [truncated]"
            truncated = True

        return ToolOutput(
            content=content,
            metadata={
                "command": params.command,
                "cwd": str(working_directory),
                "exit_code": exit_code,
                "timed_out": timed_out,
                "truncated": truncated,
            },
        )

    return Tool(
        name="run_command",
        description=RUN_COMMAND_DESCRIPTION,
        parameters=RunCommandParams,
        execute=execute,
    )


def create_write_file_tool(allowed_roots: list[Path] | None = None) -> Tool[WriteFileParams]:
    access = WorkspaceAccess(_normalize_allowed_roots(allowed_roots))

    async def execute(params: WriteFileParams, ctx: ToolContext) -> ToolOutput:
        file_path = access.resolve_file(params.path)

        if params.create_directories:
            file_path.parent.mkdir(parents=True, exist_ok=True)
        elif not file_path.parent.exists():
            raise FileNotFoundError(f"Parent directory not found: {file_path.parent}")

        if file_path.exists() and not file_path.is_file():
            raise IsADirectoryError(f"Path is not a file: {file_path}")

        mode = "a" if params.append else "w"
        with file_path.open(mode, encoding="utf-8") as file_handle:
            file_handle.write(params.content)

        action = "Appended to" if params.append else "Wrote"
        return ToolOutput(
            content=f"{action} file: {file_path}",
            metadata={
                "path": str(file_path),
                "append": params.append,
                "chars_written": len(params.content),
            },
        )

    return Tool(
        name="write_file",
        description=WRITE_FILE_DESCRIPTION,
        parameters=WriteFileParams,
        execute=execute,
    )


def create_search_files_tool(allowed_roots: list[Path] | None = None) -> Tool[SearchFilesParams]:
    access = WorkspaceAccess(_normalize_allowed_roots(allowed_roots))

    async def execute(params: SearchFilesParams, ctx: ToolContext) -> ToolOutput:
        target_path = access.resolve_file(params.path)
        matcher = _build_matcher(
            pattern=params.pattern,
            regex=params.regex,
            case_sensitive=params.case_sensitive,
        )

        if target_path.is_file():
            matches = _search_file(target_path, matcher, params.max_results)
        elif target_path.is_dir():
            matches = _search_directory(
                target_path=target_path,
                matcher=matcher,
                include_hidden=params.include_hidden,
                max_results=params.max_results,
            )
        else:
            raise FileNotFoundError(f"Search path not found: {target_path}")

        if not matches:
            return ToolOutput(
                content=f"No matches found in {target_path}",
                metadata={"path": str(target_path), "count": 0},
            )

        lines = [f"Search results for {params.pattern!r} in {target_path}:"]
        for match in matches:
            lines.append(match)

        return ToolOutput(
            content="\n".join(lines),
            metadata={
                "path": str(target_path),
                "count": len(matches),
                "regex": params.regex,
                "case_sensitive": params.case_sensitive,
            },
        )

    return Tool(
        name="search_files",
        description=SEARCH_FILES_DESCRIPTION,
        parameters=SearchFilesParams,
        execute=execute,
    )


def create_replace_in_file_tool(
    allowed_roots: list[Path] | None = None,
) -> Tool[ReplaceInFileParams]:
    access = WorkspaceAccess(_normalize_allowed_roots(allowed_roots))

    async def execute(params: ReplaceInFileParams, ctx: ToolContext) -> ToolOutput:
        file_path = access.resolve_file(params.path)

        if not file_path.exists():
            raise FileNotFoundError(f"File not found: {file_path}")

        if not file_path.is_file():
            raise IsADirectoryError(f"Path is not a file: {file_path}")

        original = file_path.read_text(encoding="utf-8", errors="replace")

        if not params.old_text:
            raise ValueError("old_text must not be empty")

        occurrences = original.count(params.old_text)
        if occurrences == 0:
            raise ValueError("old_text was not found in the target file")

        if occurrences > 1 and not params.replace_all:
            raise ValueError(
                "old_text matched multiple locations. Set replace_all=true or use a more specific old_text."
            )

        updated = (
            original.replace(params.old_text, params.new_text)
            if params.replace_all
            else original.replace(params.old_text, params.new_text, 1)
        )
        file_path.write_text(updated, encoding="utf-8")

        replaced_count = occurrences if params.replace_all else 1
        return ToolOutput(
            content=f"Updated file: {file_path} ({replaced_count} replacement{'s' if replaced_count != 1 else ''})",
            metadata={
                "path": str(file_path),
                "replaced_count": replaced_count,
                "replace_all": params.replace_all,
            },
        )

    return Tool(
        name="replace_in_file",
        description=REPLACE_IN_FILE_DESCRIPTION,
        parameters=ReplaceInFileParams,
        execute=execute,
    )


def _normalize_allowed_roots(allowed_roots: list[Path] | None) -> tuple[Path, ...]:
    if not allowed_roots:
        return ()

    normalized: list[Path] = []
    for root in allowed_roots:
        expanded = root.expanduser().resolve()
        if expanded not in normalized:
            normalized.append(expanded)
    return tuple(normalized)


def _validate_command(command: str) -> None:
    if not command.strip():
        raise ValueError("Command must not be empty")

    for token in FORBIDDEN_SHELL_TOKENS:
        if token in command:
            raise ValueError(
                f"Command contains unsupported shell operator '{token}'. "
                "Use a single executable command without pipes or redirection."
            )


def _build_matcher(
    pattern: str,
    regex: bool,
    case_sensitive: bool,
):
    if regex:
        flags = 0 if case_sensitive else re.IGNORECASE
        compiled = re.compile(pattern, flags)
        return lambda line: bool(compiled.search(line))

    if case_sensitive:
        return lambda line: pattern in line

    lowered = pattern.lower()
    return lambda line: lowered in line.lower()


def _search_directory(
    target_path: Path,
    matcher,
    include_hidden: bool,
    max_results: int,
) -> list[str]:
    matches: list[str] = []

    for file_path in sorted(target_path.rglob("*")):
        if len(matches) >= max_results:
            break
        if not file_path.is_file():
            continue
        if not include_hidden and _is_hidden(file_path.relative_to(target_path)):
            continue
        matches.extend(_search_file(file_path, matcher, max_results - len(matches), base_path=target_path))

    return matches


def _search_file(
    file_path: Path,
    matcher,
    max_results: int,
    base_path: Path | None = None,
) -> list[str]:
    matches: list[str] = []
    display_path = file_path.relative_to(base_path) if base_path else file_path

    with file_path.open("r", encoding="utf-8", errors="replace") as file_handle:
        for line_number, line in enumerate(file_handle, start=1):
            if matcher(line):
                matches.append(f"{display_path}:{line_number}: {line.rstrip()}")
                if len(matches) >= max_results:
                    break

    return matches


def _is_hidden(path: Path) -> bool:
    return any(part.startswith(".") for part in path.parts)


READ_FILE_DESCRIPTION = """Read the contents of a text file from disk.

Use this tool to:
- Inspect scripts, source files, and skill references
- Read configuration files or documentation
- View a specific line range with line numbers

Prefer this tool before running scripts you have not inspected yet."""


LIST_FILES_DESCRIPTION = """List files and directories on disk.

Use this tool to:
- Discover scripts, references, and templates bundled with a skill
- Inspect a project tree before reading or running files
- Check whether a path exists and whether it is a file or directory"""


RUN_COMMAND_DESCRIPTION = """Run a local command without shell operators.

Use this tool to:
- Execute project scripts or build commands
- Run commands referenced by a loaded skill
- Check command output before making changes

This tool rejects shell pipelines, redirection, and chained commands."""


WRITE_FILE_DESCRIPTION = """Write or append text to a file in the allowed workspace.

Use this tool to:
- Create new scripts or reference files
- Update project files after deciding on the desired content
- Append logs or generated snippets to an existing file

Inspect the destination file first when editing existing content."""


SEARCH_FILES_DESCRIPTION = """Search for text across files in the allowed workspace.

Use this tool to:
- Find code, scripts, configuration, or references by keyword
- Perform grep-like searches before reading or editing files
- Search one file or recurse through a directory

Returns file paths, line numbers, and matched lines."""


REPLACE_IN_FILE_DESCRIPTION = """Replace exact text inside a file in the allowed workspace.

Use this tool to:
- Make precise edits after inspecting a file
- Update code, config, or documentation without rewriting the whole file
- Apply one targeted replacement or replace all exact matches

This tool requires an exact old_text match and rejects ambiguous multi-match edits unless replace_all=true."""


ReadFileTool = create_read_file_tool()
ListFilesTool = create_list_files_tool()
RunCommandTool = create_run_command_tool()
WriteFileTool = create_write_file_tool()
SearchFilesTool = create_search_files_tool()
ReplaceInFileTool = create_replace_in_file_tool()
