"""Ajanın kullanabileceği araçlar: dosya okuma/yazma/düzenleme, arama, komut çalıştırma."""

from __future__ import annotations

import fnmatch
import json
import os
import re
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable

MAX_OUTPUT_CHARS = 20_000
MAX_READ_LINES = 2_000

SKIP_DIRS = {
    ".git", "node_modules", "__pycache__", ".venv", "venv", "env",
    ".idea", ".vscode", "dist", "build", ".next", ".pytest_cache",
    ".mypy_cache", "vendor", ".cache",
}
BINARY_EXTS = {
    ".png", ".jpg", ".jpeg", ".gif", ".ico", ".webp", ".bmp", ".pdf",
    ".zip", ".gz", ".tar", ".rar", ".7z", ".exe", ".dll", ".so", ".dylib",
    ".pyc", ".woff", ".woff2", ".ttf", ".eot", ".mp3", ".mp4", ".mov",
    ".sqlite", ".db", ".class", ".jar", ".bin",
}

# Onay istemeden çalışabilecek araçlar (salt okunur).
READ_ONLY = {"read_file", "list_dir", "find_files", "search_text"}


class ToolError(Exception):
    """Araç çalışırken oluşan, modele geri bildirilecek hata."""


@dataclass
class ToolResult:
    output: str
    # Onay ekranında gösterilecek zengin önizleme (diff, komut vb.)
    preview: str | None = None
    preview_kind: str = "text"  # text | diff | shell
    summary: str = ""
    meta: dict[str, Any] = field(default_factory=dict)


# ---------------------------------------------------------------- yol güvenliği

class Workspace:
    """Tüm dosya işlemlerini bir kök dizine hapseder."""

    def __init__(self, root: Path, unsafe: bool = False):
        self.root = root.resolve()
        self.unsafe = unsafe

    def resolve(self, raw: str) -> Path:
        if not raw or not str(raw).strip():
            raise ToolError("Yol boş olamaz.")
        p = Path(os.path.expandvars(str(raw).strip())).expanduser()
        p = p if p.is_absolute() else (self.root / p)
        p = Path(os.path.normpath(str(p)))
        if not self.unsafe:
            try:
                p.resolve().relative_to(self.root)
            except ValueError:
                raise ToolError(
                    f"{raw} -> çalışma dizininin ({self.root}) dışında, erişim reddedildi. "
                    f"Gerekiyorsa --unsafe ile başlatın."
                ) from None
        return p

    def rel(self, p: Path) -> str:
        try:
            return str(p.resolve().relative_to(self.root)).replace("\\", "/")
        except ValueError:
            return str(p)


def _looks_binary(path: Path) -> bool:
    if path.suffix.lower() in BINARY_EXTS:
        return True
    try:
        with path.open("rb") as fh:
            return b"\0" in fh.read(4096)
    except OSError:
        return False


def _read_text(path: Path) -> str:
    for enc in ("utf-8", "utf-8-sig", "cp1254", "latin-1"):
        try:
            return path.read_text(encoding=enc)
        except UnicodeDecodeError:
            continue
    raise ToolError(f"{path.name}: metin olarak çözülemedi (ikili dosya olabilir).")


def _clip(text: str, limit: int = MAX_OUTPUT_CHARS) -> str:
    if len(text) <= limit:
        return text
    return text[:limit] + f"\n\n... [{len(text) - limit} karakter kırpıldı]"


def _unified_diff(old: str, new: str, label: str) -> str:
    import difflib

    lines = list(
        difflib.unified_diff(
            old.splitlines(keepends=True),
            new.splitlines(keepends=True),
            fromfile="a/" + label,
            tofile="b/" + label,
            n=3,
        )
    )
    if not lines:
        return "(içerik aynı)"
    return _clip("".join(lines), 8000)


# ---------------------------------------------------------------- araç gövdeleri

def t_list_dir(ws: Workspace, path: str = ".", depth: int = 1) -> ToolResult:
    root = ws.resolve(path)
    if not root.exists():
        raise ToolError(f"Bulunamadı: {ws.rel(root)}")
    if not root.is_dir():
        raise ToolError(f"Dizin değil: {ws.rel(root)}")
    depth = max(1, min(int(depth), 4))

    rows: list[str] = []
    count = 0

    def walk(d: Path, level: int, prefix: str) -> None:
        nonlocal count
        if level > depth or count > 600:
            return
        try:
            entries = sorted(d.iterdir(), key=lambda e: (e.is_file(), e.name.lower()))
        except OSError as exc:
            rows.append(f"{prefix}[okunamadı: {exc}]")
            return
        for e in entries:
            if e.name in SKIP_DIRS:
                continue
            if e.is_dir() and e.name.startswith("."):
                continue
            count += 1
            if count > 600:
                rows.append(f"{prefix}... (kırpıldı)")
                return
            if e.is_dir():
                rows.append(f"{prefix}{e.name}/")
                walk(e, level + 1, prefix + "  ")
            else:
                try:
                    size = e.stat().st_size
                except OSError:
                    size = 0
                rows.append(f"{prefix}{e.name}  ({size:,} B)")

    walk(root, 1, "")
    body = "\n".join(rows) or "(boş dizin)"
    return ToolResult(
        output=f"{ws.rel(root)}/\n{body}",
        summary=f"{ws.rel(root)}/ listelendi ({count} öğe)",
    )


def t_read_file(ws: Workspace, path: str, offset: int = 1,
                limit: int = MAX_READ_LINES) -> ToolResult:
    p = ws.resolve(path)
    if not p.exists():
        raise ToolError(f"Dosya yok: {ws.rel(p)}")
    if p.is_dir():
        raise ToolError(f"{ws.rel(p)} bir dizin, list_dir kullanın.")
    if _looks_binary(p):
        raise ToolError(f"{ws.rel(p)} ikili bir dosya, metin olarak okunamaz.")

    text = _read_text(p)
    lines = text.splitlines()
    offset = max(1, int(offset))
    limit = max(1, min(int(limit), MAX_READ_LINES))
    chunk = lines[offset - 1: offset - 1 + limit]
    width = len(str(offset + len(chunk)))
    numbered = "\n".join(
        f"{offset + i:>{width}}|{ln}" for i, ln in enumerate(chunk)
    )
    tail = ""
    if offset - 1 + len(chunk) < len(lines):
        tail = f"\n\n... toplam {len(lines)} satır, {offset + len(chunk)}. satırdan devam ediyor."
    return ToolResult(
        output=_clip(numbered + tail) or "(boş dosya)",
        summary=f"{ws.rel(p)} okundu ({len(chunk)}/{len(lines)} satır)",
    )


def t_write_file(ws: Workspace, path: str, content: str) -> ToolResult:
    p = ws.resolve(path)
    existed = p.exists()
    if existed and _looks_binary(p):
        raise ToolError(f"{ws.rel(p)} ikili dosya, üzerine yazılmayacak.")
    old = _read_text(p) if existed else ""

    preview = _unified_diff(old, content, ws.rel(p)) if existed else _clip(content, 8000)
    return ToolResult(
        output="",  # asıl yazma commit() ile yapılır
        preview=preview,
        preview_kind="diff" if existed else "text",
        summary=("üzerine yaz" if existed else "yeni dosya") + f": {ws.rel(p)}",
        meta={"apply": ("write", p, content), "existed": existed, "old": old, "path": p},
    )


def t_edit_file(ws: Workspace, path: str, old_string: str, new_string: str,
                replace_all: bool = False) -> ToolResult:
    p = ws.resolve(path)
    if not p.exists():
        raise ToolError(f"Dosya yok: {ws.rel(p)} (önce write_file ile oluşturun)")
    old = _read_text(p)
    if old_string == new_string:
        raise ToolError("old_string ve new_string aynı, değişiklik yok.")
    hits = old.count(old_string)
    if hits == 0:
        raise ToolError(
            f"{ws.rel(p)} içinde old_string bulunamadı. "
            "Önce read_file ile tam metni (girinti ve boşluklar dahil) doğrulayın."
        )
    if hits > 1 and not replace_all:
        raise ToolError(
            f"old_string {hits} kez geçiyor. Benzersiz olacak kadar bağlam ekleyin "
            "veya replace_all=true verin."
        )
    new = old.replace(old_string, new_string) if replace_all \
        else old.replace(old_string, new_string, 1)
    return ToolResult(
        output="",
        preview=_unified_diff(old, new, ws.rel(p)),
        preview_kind="diff",
        summary=f"düzenle: {ws.rel(p)} ({hits if replace_all else 1} eşleşme)",
        meta={"apply": ("write", p, new), "existed": True, "old": old, "path": p},
    )


def t_find_files(ws: Workspace, pattern: str = "*", path: str = ".",
                 limit: int = 200) -> ToolResult:
    root = ws.resolve(path)
    if not root.is_dir():
        raise ToolError(f"Dizin değil: {ws.rel(root)}")
    limit = max(1, min(int(limit), 1000))
    out: list[str] = []
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
        for fn in filenames:
            full = Path(dirpath) / fn
            rel = ws.rel(full)
            if fnmatch.fnmatch(fn, pattern) or fnmatch.fnmatch(rel, pattern):
                out.append(rel)
                if len(out) >= limit:
                    break
        if len(out) >= limit:
            break
    body = "\n".join(sorted(out)) or f"'{pattern}' ile eşleşen dosya yok."
    return ToolResult(output=_clip(body), summary=f"find '{pattern}' -> {len(out)} dosya")


def t_search_text(ws: Workspace, pattern: str, path: str = ".", glob: str = "*",
                  ignore_case: bool = True, limit: int = 100) -> ToolResult:
    root = ws.resolve(path)
    try:
        rx = re.compile(pattern, re.IGNORECASE if ignore_case else 0)
    except re.error as exc:
        raise ToolError(f"Geçersiz regex: {exc}") from None
    limit = max(1, min(int(limit), 500))

    hits: list[str] = []
    scanned = 0
    targets: list[Path] = []
    if root.is_file():
        targets = [root]
    else:
        for dirpath, dirnames, filenames in os.walk(root):
            dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
            for fn in filenames:
                if fnmatch.fnmatch(fn, glob):
                    targets.append(Path(dirpath) / fn)

    for f in targets:
        if len(hits) >= limit:
            break
        if _looks_binary(f):
            continue
        scanned += 1
        try:
            with f.open("r", encoding="utf-8", errors="replace") as fh:
                for i, line in enumerate(fh, 1):
                    if rx.search(line):
                        hits.append(f"{ws.rel(f)}:{i}: {line.rstrip()[:300]}")
                        if len(hits) >= limit:
                            break
        except OSError:
            continue

    body = "\n".join(hits) or f"'{pattern}' için eşleşme yok ({scanned} dosya tarandı)."
    return ToolResult(output=_clip(body), summary=f"grep '{pattern}' -> {len(hits)} satır")


def t_run_command(ws: Workspace, command: str, timeout: int = 120,
                  shell: str = "") -> ToolResult:
    command = (command or "").strip()
    if not command:
        raise ToolError("Komut boş.")
    timeout = max(1, min(int(timeout), 600))
    return ToolResult(
        output="",
        preview=command,
        preview_kind="shell",
        summary=f"komut: {command[:80]}",
        meta={"apply": ("run", command, timeout, shell)},
    )


def _do_run(ws: Workspace, command: str, timeout: int, shell: str) -> str:
    if shell:
        if "powershell" in shell.lower() or "pwsh" in shell.lower():
            args: Any = [shell, "-NoProfile", "-NonInteractive", "-Command", command]
        else:
            args = [shell, "-c", command]
        use_shell = False
    else:
        args = command
        use_shell = True
    try:
        proc = subprocess.run(
            args,
            shell=use_shell,
            cwd=str(ws.root),
            capture_output=True,
            timeout=timeout,
            text=True,
            encoding="utf-8",
            errors="replace",
        )
    except subprocess.TimeoutExpired:
        return f"$ {command}\n[zaman aşımı: {timeout}s içinde bitmedi]"
    except OSError as exc:
        return f"$ {command}\n[çalıştırılamadı: {exc}]"

    parts = [f"$ {command}", f"[çıkış kodu: {proc.returncode}]"]
    out = (proc.stdout or "").strip()
    err = (proc.stderr or "").strip()
    if out:
        parts.append("--- stdout ---\n" + out)
    if err:
        parts.append("--- stderr ---\n" + err)
    if not out and not err:
        parts.append("(çıktı yok)")
    return _clip("\n".join(parts))


def commit(ws: Workspace, result: ToolResult) -> str:
    """Onaylanmış bir aracın yan etkisini uygular, modele dönecek metni üretir."""
    apply = result.meta.get("apply")
    if not apply:
        return result.output
    kind = apply[0]
    if kind == "write":
        _, path, content = apply
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(content, encoding="utf-8", newline="")
        n = len(content.splitlines())
        verb = "güncellendi" if result.meta.get("existed") else "oluşturuldu"
        return f"{ws.rel(path)} {verb} ({n} satır, {len(content)} karakter)."
    if kind == "run":
        _, command, timeout, shell = apply
        return _do_run(ws, command, timeout, shell)
    return result.output


# ---------------------------------------------------------------- kayıt / şema

HANDLERS: dict[str, Callable[..., ToolResult]] = {
    "list_dir": t_list_dir,
    "read_file": t_read_file,
    "write_file": t_write_file,
    "edit_file": t_edit_file,
    "find_files": t_find_files,
    "search_text": t_search_text,
    "run_command": t_run_command,
}

SCHEMAS: list[dict[str, Any]] = [
    {
        "type": "function",
        "function": {
            "name": "list_dir",
            "description": "Bir dizinin içeriğini listeler. Projeyi keşfetmek için ilk adım.",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "Dizin yolu (varsayılan '.')"},
                    "depth": {"type": "integer", "description": "İç içe derinlik 1-4"},
                },
                "required": [],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": "Metin dosyasını satır numaralarıyla okur. Bir dosyayı düzenlemeden ÖNCE mutlaka çağır.",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "Dosya yolu"},
                    "offset": {"type": "integer", "description": "Başlangıç satırı (1 tabanlı)"},
                    "limit": {"type": "integer", "description": "Okunacak satır sayısı"},
                },
                "required": ["path"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "write_file",
            "description": "Dosyayı verilen içerikle baştan yazar. Yeni dosya oluşturmak için kullan. Mevcut dosyanın küçük bir kısmını değiştireceksen edit_file tercih et.",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {"type": "string"},
                    "content": {"type": "string", "description": "Dosyanın tam içeriği"},
                },
                "required": ["path", "content"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "edit_file",
            "description": "Dosyada birebir metin değişimi yapar. old_string dosyada BENZERSİZ olmalı; değilse yeterince bağlam ekle veya replace_all=true ver.",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {"type": "string"},
                    "old_string": {"type": "string", "description": "Değişecek metin, birebir (girinti dahil)"},
                    "new_string": {"type": "string", "description": "Yerine gelecek metin"},
                    "replace_all": {"type": "boolean", "description": "Tüm eşleşmeleri değiştir"},
                },
                "required": ["path", "old_string", "new_string"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "find_files",
            "description": "Glob deseniyle dosya adı arar. Örnek: '*.py', 'test_*.js'.",
            "parameters": {
                "type": "object",
                "properties": {
                    "pattern": {"type": "string", "description": "Glob deseni"},
                    "path": {"type": "string", "description": "Arama kökü"},
                    "limit": {"type": "integer"},
                },
                "required": ["pattern"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "search_text",
            "description": "Dosya içeriklerinde regex arar (grep). Bir fonksiyonun nerede tanımlandığını bulmak için ideal.",
            "parameters": {
                "type": "object",
                "properties": {
                    "pattern": {"type": "string", "description": "Regex deseni"},
                    "path": {"type": "string", "description": "Arama kökü"},
                    "glob": {"type": "string", "description": "Dosya adı filtresi, örn '*.py'"},
                    "ignore_case": {"type": "boolean"},
                    "limit": {"type": "integer"},
                },
                "required": ["pattern"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "run_command",
            "description": "Kabuk komutu çalıştırır (test, build, git, pip vb.). Çalışma dizini proje köküdür. Etkileşim bekleyen komutlar çalıştırma.",
            "parameters": {
                "type": "object",
                "properties": {
                    "command": {"type": "string"},
                    "timeout": {"type": "integer", "description": "Saniye, varsayılan 120"},
                },
                "required": ["command"],
            },
        },
    },
]


def call(ws: Workspace, name: str, args: dict[str, Any], shell: str = "") -> ToolResult:
    fn = HANDLERS.get(name)
    if fn is None:
        raise ToolError(
            f"Bilinmeyen araç: {name}. Kullanılabilir: {', '.join(sorted(HANDLERS))}"
        )
    if not isinstance(args, dict):
        raise ToolError(f"{name}: arguments bir JSON nesnesi olmalı.")
    if name == "run_command":
        args = {**args, "shell": shell}
    try:
        return fn(ws, **args)
    except ToolError:
        raise
    except TypeError as exc:
        raise ToolError(f"{name} için hatalı parametreler: {exc}") from None
    except Exception as exc:  # noqa: BLE001 - hatayı modele geri bildiriyoruz
        raise ToolError(f"{name} başarısız: {type(exc).__name__}: {exc}") from None


# ------------------------------------------------- native tool-calling olmayan sunucular

def prompt_mode_docs() -> str:
    lines = [
        "ARAÇ KULLANIMI",
        "Bir araç çağırmak için yanıtına şu bloğu ekle:",
        "<tool_call>",
        '{"name": "arac_adi", "arguments": {"param": "deger"}}',
        "</tool_call>",
        "",
        "Bloğu yazdıktan sonra dur ve sonucu bekle. Kullanılabilir araçlar:",
        "",
    ]
    for s in SCHEMAS:
        f = s["function"]
        lines.append(f"- {f['name']}: {f['description']}")
        lines.append(
            "  parametreler: "
            + json.dumps(f["parameters"]["properties"], ensure_ascii=False)
        )
        lines.append(f"  zorunlu: {f['parameters'].get('required') or []}")
    return "\n".join(lines)


TOOL_CALL_RE = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.DOTALL)


def parse_prompt_tool_calls(text: str) -> list[dict[str, Any]]:
    calls = []
    for i, m in enumerate(TOOL_CALL_RE.finditer(text)):
        try:
            data = json.loads(m.group(1))
        except json.JSONDecodeError:
            continue
        name = data.get("name")
        if not name:
            continue
        args = data.get("arguments", data.get("parameters", {}))
        if isinstance(args, str):
            try:
                args = json.loads(args)
            except json.JSONDecodeError:
                args = {}
        calls.append({"id": f"call_{i}", "name": name, "args": args or {}})
    return calls


def strip_tool_calls(text: str) -> str:
    return TOOL_CALL_RE.sub("", text).strip()
