"""ReadFileTool — file reading with optional symbol-level sharding (G5, R22/R23).

Backward compatible with the pre-existing `_FakeTool` benchmark shape — when
`symbol=None`, returns the full file content. When `symbol="foo"`, returns
the line range of the first matching symbol via `SymbolExtractor`.

KTD2 (Wave 3 plan): dedicated tool, does NOT extend ShellTool — keeps the
file-reading contract clean and gives the LLM a focused schema.
"""

from __future__ import annotations

import logging
from pathlib import Path

from agentkit.tools.base import Tool
from agentkit.tools.symbol_extractor import (
    SymbolSpan,
    extract_symbols_from_file,
    language_for_extension,
)

logger = logging.getLogger(__name__)


class ReadFileTool(Tool):
    """Read a file from the filesystem, optionally sliced to a single symbol.

    Tool name `read_file` matches the reserved entry in
    `core/react.py:_DEFAULT_CORE_TOOLS` (which previously had no real
    implementation — only `_FakeTool` stubs in `cli/benchmark.py`).

    Backward-compat contract: `symbol=None` returns the full file content,
    matching the shape `{"path": ...}` that downstream callers (benchmark,
    phase whitelist) already expect.
    """

    def __init__(
        self,
        name: str = "read_file",
        description: str | None = None,
        input_schema: dict[str, object] | None = None,
        output_schema: dict[str, object] | None = None,
        version: str = "1.0.0",
        tags: list[str] | None = None,
    ):
        super().__init__(
            name=name,
            description=description
            or (
                "Read a file from the filesystem. By default returns the full file "
                "content. Pass `symbol` (function/class/struct name) to slice to just "
                "that symbol's line range — saves context when you only need one "
                "function from a large file. Pass `start_line`/`end_line` for manual "
                "slicing. If `symbol` is set but not found, returns the available "
                "symbol names so you can retry."
            ),
            input_schema=input_schema or self._default_input_schema(),
            output_schema=output_schema or self._default_output_schema(),
            version=version,
            tags=tags or ["io", "file", "read"],
        )

    @staticmethod
    def _default_input_schema() -> dict[str, object]:
        return {
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "Path to the file to read (absolute or relative to cwd).",
                },
                "symbol": {
                    "type": "string",
                    "description": (
                        "Optional: name of a function/class/struct/method to slice to. "
                        "When set, returns only the line range of the first matching "
                        "symbol. Supported languages: py, ts/tsx, js/jsx, go, rs, java."
                    ),
                },
                "start_line": {
                    "type": "integer",
                    "description": "Optional 1-based start line for manual slicing. Overrides `symbol`.",
                    "minimum": 1,
                },
                "end_line": {
                    "type": "integer",
                    "description": "Optional 1-based end line (inclusive) for manual slicing. Overrides `symbol`.",
                    "minimum": 1,
                },
            },
            "required": ["path"],
            "additionalProperties": False,
        }

    @staticmethod
    def _default_output_schema() -> dict[str, object]:
        return {
            "type": "object",
            "properties": {
                "content": {"type": "string"},
                "path": {"type": "string"},
                "start_line": {"type": "integer"},
                "end_line": {"type": "integer"},
                "symbol": {"type": "string"},
                "available_symbols": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "Populated when `symbol` is set but not found.",
                },
                "note": {"type": "string"},
                "is_error": {"type": "boolean"},
                "error": {"type": "string"},
            },
        }

    async def execute(self, **kwargs) -> dict[str, object]:
        raw_path = kwargs.get("path")
        if not raw_path:
            return self._error("`path` is required")

        path = Path(raw_path)
        if not path.is_absolute():
            path = path.resolve()

        symbol = kwargs.get("symbol")
        start_line = kwargs.get("start_line")
        end_line = kwargs.get("end_line")

        # Validate/sanitize line overrides.
        if start_line is not None and (not isinstance(start_line, int) or start_line < 1):
            return self._error(f"`start_line` must be a positive integer, got {start_line!r}")
        if end_line is not None and (not isinstance(end_line, int) or end_line < 1):
            return self._error(f"`end_line` must be a positive integer, got {end_line!r}")
        if start_line is not None and end_line is not None and end_line < start_line:
            return self._error(f"`end_line` ({end_line}) must be >= `start_line` ({start_line})")

        # Filesystem checks.
        if not path.exists():
            return self._error(f"File not found: {path}", path=str(path))
        if path.is_dir():
            return self._error(f"Path is a directory, not a file: {path}", path=str(path))

        try:
            content = path.read_text(encoding="utf-8", errors="replace")
        except PermissionError as e:
            return self._error(f"Permission denied: {path}", path=str(path), detail=str(e))
        except OSError as e:
            return self._error(f"Failed to read {path}: {e}", path=str(path))

        lines = content.splitlines()
        total_lines = len(lines)

        # Manual slicing takes precedence over symbol (per plan U1 Approach).
        if start_line is not None or end_line is not None:
            s = max(1, start_line or 1)
            e = min(total_lines, end_line or total_lines)
            sliced = "\n".join(lines[s - 1 : e])
            return {
                "content": sliced,
                "path": str(path),
                "start_line": s,
                "end_line": e,
                "total_lines": total_lines,
                "is_error": False,
            }

        # Symbol slicing.
        if symbol:
            ext = path.suffix.lower()
            language = language_for_extension(ext)
            if not language:
                # Unsupported extension: return full file with note (per plan U1 Edge case).
                return {
                    "content": content,
                    "path": str(path),
                    "start_line": 1,
                    "end_line": total_lines,
                    "total_lines": total_lines,
                    "note": f"symbol extraction not supported for {ext or 'unknown extension'}",
                    "is_error": False,
                }

            spans, _lang = extract_symbols_from_file(path)
            # Re-extract using the content we already read so we don't read the file twice.
            if not spans:
                # Try extraction from in-memory content (path-based extraction may
                # have failed silently on OSError; we already read it successfully).
                from agentkit.tools.symbol_extractor import get_extractor

                extractor = get_extractor(language)
                if extractor is not None:
                    spans = extractor.extract_symbols(content, language)

            match = _find_symbol(spans, symbol)
            if match is None:
                available = sorted({s.name for s in spans})
                return {
                    "content": "",
                    "path": str(path),
                    "symbol": symbol,
                    "available_symbols": available,
                    "is_error": False,
                    "note": (
                        f"Symbol {symbol!r} not found in {path.name}. "
                        f"Available: {', '.join(available) if available else '(none)'}"
                    ),
                }

            s = match.start_line
            e = min(match.end_line, total_lines)
            sliced = "\n".join(lines[s - 1 : e])
            return {
                "content": sliced,
                "path": str(path),
                "symbol": symbol,
                "symbol_kind": match.kind,
                "start_line": s,
                "end_line": e,
                "total_lines": total_lines,
                "is_error": False,
            }

        # Default: full file (characterization baseline — matches _FakeTool shape).
        return {
            "content": content,
            "path": str(path),
            "start_line": 1,
            "end_line": total_lines,
            "total_lines": total_lines,
            "is_error": False,
        }

    @staticmethod
    def _error(
        message: str, *, path: str | None = None, detail: str | None = None
    ) -> dict[str, object]:
        result: dict[str, object] = {
            "content": "",
            "is_error": True,
            "error": message,
        }
        if path is not None:
            result["path"] = path
        if detail is not None:
            result["detail"] = detail
        return result


def _find_symbol(spans: list[SymbolSpan], name: str) -> SymbolSpan | None:
    """Find the first symbol matching `name`. Case-sensitive.

    ponytail: linear scan is fine for typical file symbol counts (<100). The
    extractor already returns symbols sorted by start_line; first match wins
    for ambiguous overloads (e.g., Python classes with same name in different
    modules — not relevant within one file).
    """
    for span in spans:
        if span.name == name:
            return span
    return None
