"""
Local tool executor — runs agent tool calls on the user's machine.

When the cloud worker needs to read/write/edit files or run commands,
it sends a tool_request via WebSocket. This module executes the tool
locally and returns the result.
"""

from __future__ import annotations

import difflib
import json
import logging
import os
import pathlib
import re
import shutil
import subprocess
from typing import Any, Dict

logger = logging.getLogger("sigmanticai.tools")

MAX_OUTPUT_CHARS = 12_000
MAX_TIMEOUT = 120
MAX_DEFAULT_LINES = 2000

# ── Path remapping ────────────────────────────────────────
# The cloud worker runs at /app/ on ECS, but the CLI executes tools
# locally on the user's machine.  set_local_base() tells us where
# /app/ should map to so file operations land in the right place.
_local_base: str | None = None


def set_local_base(path: str) -> None:
    """Set the local directory that replaces ``/app/`` in worker paths."""
    global _local_base
    _local_base = path
    logger.debug("tool_executor: local base set to %s", path)


def _remap_path(p: str) -> str:
    """Replace a leading ``/app/`` prefix with the local base directory."""
    if _local_base and p.startswith("/app/"):
        return _local_base + p[5:]
    return p


def execute_tool(tool_name: str, args_json: str) -> str:
    try:
        args = json.loads(args_json) if isinstance(args_json, str) else args_json
    except json.JSONDecodeError:
        return f"Error: Invalid tool args JSON: {args_json[:200]}"

    handlers = {
        "read": _exec_read,
        "write": _exec_write,
        "edit": _exec_edit,
        "apply_diff": _exec_apply_diff,
        "grep_search": _exec_grep_search,
        "glob_search": _exec_glob_search,
        "list_directory": _exec_list_directory,
        "bash": _exec_bash,
        "run_simulation": _exec_run_simulation,
    }
    handler = handlers.get(tool_name)
    if not handler:
        return f"Error: Unknown tool: {tool_name}"
    try:
        return handler(**args)
    except TypeError as e:
        return f"Error: Bad arguments for {tool_name}: {e}"
    except Exception as e:
        logger.error("Tool %s failed: %s", tool_name, e, exc_info=True)
        return f"Error executing {tool_name}: {e}"


def _exec_read(file_path: str, offset: int = 0, limit: int = 0) -> str:
    file_path = _remap_path(file_path)
    if not os.path.isfile(file_path):
        return f"Error: File not found: {file_path}"
    try:
        with open(file_path, "r", encoding="utf-8", errors="replace") as f:
            lines = f.readlines()
    except PermissionError:
        return f"Error: Permission denied: {file_path}"
    except Exception as e:
        return f"Error reading file: {e}"

    total_lines = len(lines)
    if offset > 0:
        lines = lines[offset:]
    user_requested_all = (limit == 0)
    if limit > 0:
        lines = lines[:limit]
    elif len(lines) > MAX_DEFAULT_LINES:
        lines = lines[:MAX_DEFAULT_LINES]

    start_line = offset + 1
    numbered = [f"{start_line + i:>6}\t{line.rstrip()}" for i, line in enumerate(lines)]
    result = "\n".join(numbered)

    if user_requested_all and total_lines > MAX_DEFAULT_LINES + offset:
        remaining = total_lines - (offset + MAX_DEFAULT_LINES)
        result += (
            f"\n\n... [{remaining} more lines not shown. "
            f"File has {total_lines} total lines. "
            f"Use offset={offset + MAX_DEFAULT_LINES} to continue reading.]"
        )
    return result


def _exec_write(file_path: str, content: str) -> str:
    file_path = _remap_path(file_path)
    try:
        dir_path = os.path.dirname(file_path)
        if dir_path:
            os.makedirs(dir_path, exist_ok=True)
        with open(file_path, "w", encoding="utf-8") as f:
            f.write(content)
        return f"Successfully wrote {len(content)} bytes to {file_path}"
    except PermissionError:
        return f"Error: Permission denied: {file_path}"
    except Exception as e:
        return f"Error writing file: {e}"


def _exec_edit(file_path: str, old_string: str, new_string: str, replace_all: bool = False) -> str:
    file_path = _remap_path(file_path)
    if not os.path.isfile(file_path):
        return f"Error: File not found: {file_path}"
    try:
        with open(file_path, "r", encoding="utf-8") as f:
            content = f.read()
    except Exception as e:
        return f"Error reading file: {e}"

    if old_string not in content:
        return f"Error: old_string not found in {file_path}"

    if not replace_all:
        count = content.count(old_string)
        if count > 1:
            return (
                f"Error: old_string appears {count} times in {file_path}. "
                "Provide more context to make it unique, or set replace_all=True."
            )

    if replace_all:
        new_content = content.replace(old_string, new_string)
        replacements = content.count(old_string)
    else:
        new_content = content.replace(old_string, new_string, 1)
        replacements = 1

    try:
        with open(file_path, "w", encoding="utf-8") as f:
            f.write(new_content)
        return f"Successfully replaced {replacements} occurrence(s) in {file_path}"
    except Exception as e:
        return f"Error writing file: {e}"


def _exec_apply_diff(file_path: str, old_code: str, new_code: str) -> str:
    file_path = _remap_path(file_path)
    if not os.path.isfile(file_path):
        return f"Error: File not found: {file_path}"
    try:
        with open(file_path, "r", encoding="utf-8") as f:
            content = f.read()
    except Exception as e:
        return f"Error reading file: {e}"

    if old_code in content:
        new_content = content.replace(old_code, new_code, 1)
        return _write_diff(file_path, new_content, "exact")

    pos = _find_fuzzy(content, old_code)
    if pos:
        start, end = pos
        return _write_diff(file_path, content[:start] + new_code + content[end:], "fuzzy")

    pos = _find_similarity(content, old_code)
    if pos:
        start, end = pos
        return _write_diff(file_path, content[:start] + new_code + content[end:], "similarity")

    return f"Error: Could not find matching code block in {file_path}"


def _write_diff(file_path, new_content, method):
    try:
        with open(file_path, "w", encoding="utf-8") as f:
            f.write(new_content)
        return f"Successfully applied diff to {file_path} (matched via {method})"
    except Exception as e:
        return f"Error writing file: {e}"


def _find_fuzzy(content, old_code):
    content_lines = content.split("\n")
    old_lines = old_code.strip().split("\n")
    if not old_lines:
        return None
    old_stripped = [line.strip() for line in old_lines]
    for i in range(len(content_lines) - len(old_lines) + 1):
        candidate = [content_lines[j].strip() for j in range(i, i + len(old_lines))]
        if candidate == old_stripped:
            start = sum(len(content_lines[j]) + 1 for j in range(i))
            end = sum(len(content_lines[j]) + 1 for j in range(i + len(old_lines)))
            if end > len(content):
                end = len(content)
            return (start, end)
    return None


def _find_similarity(content, old_code, threshold=0.75):
    content_lines = content.split("\n")
    old_lines = old_code.strip().split("\n")
    if not old_lines:
        return None
    best_ratio = 0.0
    best_pos = None
    window = len(old_lines)
    for i in range(len(content_lines) - window + 1):
        candidate = "\n".join(content_lines[i:i + window])
        ratio = difflib.SequenceMatcher(None, candidate, old_code.strip()).ratio()
        if ratio > best_ratio:
            best_ratio = ratio
            best_pos = i
    if best_ratio >= threshold and best_pos is not None:
        start = sum(len(content_lines[j]) + 1 for j in range(best_pos))
        end = sum(len(content_lines[j]) + 1 for j in range(best_pos + window))
        if end > len(content):
            end = len(content)
        return (start, end)
    return None


def _exec_grep_search(pattern: str, path: str = ".", glob: str = "", include_line_numbers: bool = True, max_results: int = 100, case_insensitive: bool = False) -> str:
    path = _remap_path(path)
    rg_path = shutil.which("rg")
    if not rg_path:
        return _fallback_grep(pattern, path, glob, include_line_numbers, max_results)
    cmd = [rg_path, "--no-heading", "--with-filename"]
    if include_line_numbers:
        cmd.append("--line-number")
    if case_insensitive:
        cmd.append("--ignore-case")
    if max_results:
        cmd.extend(["--max-count", str(max_results)])
    if glob:
        cmd.extend(["--glob", glob])
    cmd.append(pattern)
    cmd.append(path)
    try:
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
        if result.returncode == 0:
            return result.stdout.strip() or "No matches found."
        elif result.returncode == 1:
            return "No matches found."
        else:
            return f"Error: {result.stderr.strip()}"
    except subprocess.TimeoutExpired:
        return "Error: Search timed out after 30 seconds."
    except Exception as e:
        return f"Error running ripgrep: {e}"


def _fallback_grep(pattern, path, glob_pattern, include_line_numbers, max_results):
    try:
        regex = re.compile(pattern)
    except re.error as e:
        return f"Error: Invalid regex pattern: {e}"
    search_path = pathlib.Path(path)
    if not search_path.exists():
        return f"Error: Path not found: {path}"
    results = []
    file_pattern = glob_pattern if glob_pattern else "**/*"
    files = [search_path] if search_path.is_file() else list(search_path.glob(file_pattern))
    for fp in files:
        if not fp.is_file():
            continue
        try:
            with open(fp, "r", encoding="utf-8", errors="replace") as f:
                for line_num, line in enumerate(f, 1):
                    if regex.search(line):
                        prefix = f"{fp}:{line_num}:" if include_line_numbers else f"{fp}:"
                        results.append(f"{prefix}{line.rstrip()}")
                        if len(results) >= max_results:
                            break
        except (PermissionError, IsADirectoryError):
            continue
        if len(results) >= max_results:
            break
    return "\n".join(results) if results else "No matches found."


def _exec_glob_search(pattern: str, path: str = ".", max_results: int = 200) -> str:
    path = _remap_path(path)
    search_path = pathlib.Path(path)
    if not search_path.exists():
        return f"Error: Path not found: {path}"
    if not search_path.is_dir():
        return f"Error: Not a directory: {path}"
    try:
        matches = []
        for m in search_path.glob(pattern):
            if m.is_file():
                matches.append(str(m))
                if len(matches) >= max_results:
                    break
        if not matches:
            return f"No files matching pattern: {pattern}"
        matches.sort(key=lambda p: os.path.getmtime(p), reverse=True)
        return "\n".join(matches)
    except Exception as e:
        return f"Error during glob search: {e}"


def _exec_list_directory(path: str = ".", show_hidden: bool = False) -> str:
    path = _remap_path(path)
    if not os.path.exists(path):
        return f"Error: Path not found: {path}"
    if not os.path.isdir(path):
        return f"Error: Not a directory: {path}"
    try:
        entries = os.listdir(path)
    except PermissionError:
        return f"Error: Permission denied: {path}"
    if not show_hidden:
        entries = [e for e in entries if not e.startswith(".")]
    entries.sort(key=lambda e: (not os.path.isdir(os.path.join(path, e)), e.lower()))
    lines = []
    for entry in entries:
        full_path = os.path.join(path, entry)
        if os.path.isdir(full_path):
            lines.append(f"  {entry}/")
        elif os.path.islink(full_path):
            lines.append(f"  {entry} -> {os.readlink(full_path)}")
        else:
            size = os.path.getsize(full_path)
            lines.append(f"  {entry} ({_fmt_size(size)})")
    return f"{path}/\n" + "\n".join(lines) if lines else f"{path}/ (empty)"


def _fmt_size(size):
    for unit in ("B", "KB", "MB", "GB"):
        if size < 1024:
            return f"{size:.0f}{unit}" if unit == "B" else f"{size:.1f}{unit}"
        size /= 1024
    return f"{size:.1f}TB"


def _exec_run_simulation(output_dir: str, testbench: str = "", design_name: str = "") -> str:
    output_dir = _remap_path(output_dir)
    if testbench:
        testbench = _remap_path(testbench)
    import glob as glob_mod
    if not output_dir or not os.path.isdir(output_dir):
        return f"Error: output_dir '{output_dir}' does not exist or is not a directory."
    rtl_dir = os.path.join(output_dir, "rtl")
    rtl_files = sorted(glob_mod.glob(os.path.join(rtl_dir, "*.sv")))
    if not rtl_files:
        return f"Error: No RTL (.sv) files found in {rtl_dir}"
    sim_dir = os.path.join(output_dir, "sim")
    if testbench:
        tb_path = os.path.join(output_dir, testbench) if not os.path.isabs(testbench) else testbench
    else:
        tb_files = sorted(glob_mod.glob(os.path.join(sim_dir, "tb_*.sv")))
        if not tb_files:
            tb_files = sorted(glob_mod.glob(os.path.join(sim_dir, "*.sv")))
        if not tb_files:
            return f"Error: No testbench files found in {sim_dir}"
        tb_path = tb_files[0]
    if not os.path.isfile(tb_path):
        return f"Error: Testbench not found: {tb_path}"
    obj_dir = os.path.join(output_dir, "obj_dir")
    top_module = os.path.splitext(os.path.basename(tb_path))[0]
    compile_cmd = (
        f"verilator --binary --timing -Wall -Wno-fatal "
        f"--top-module {top_module} -Mdir {obj_dir} "
        f"{tb_path} {' '.join(rtl_files)}"
    )
    compile_result = _exec_bash(compile_cmd, timeout=60, working_dir=output_dir)
    binary = os.path.join(obj_dir, f"V{top_module}")
    if not os.path.isfile(binary):
        return f"Compilation failed:\n{compile_result}"
    run_result = _exec_bash(binary, timeout=30, working_dir=output_dir)
    return f"=== Compilation ===\n{compile_result}\n\n=== Simulation ===\n{run_result}"


def _exec_bash(command: str, timeout: int = 120, working_dir: str = "") -> str:
    if not command.strip():
        return "Error: Empty command"
    if _local_base:
        command = command.replace("/app/", _local_base)
    if working_dir:
        working_dir = _remap_path(working_dir)
    timeout = min(int(timeout), MAX_TIMEOUT)
    try:
        result = subprocess.run(
            command, shell=True, capture_output=True, text=True,
            timeout=timeout, cwd=working_dir or None,
            stdin=subprocess.DEVNULL, start_new_session=True,
        )
        output_parts = []
        if result.stdout:
            output_parts.append(result.stdout)
        if result.stderr:
            output_parts.append(f"[stderr]\n{result.stderr}")
        if result.returncode != 0:
            output_parts.append(f"[exit code: {result.returncode}]")
        output = "\n".join(output_parts).strip() or "(no output)"
        if len(output) > MAX_OUTPUT_CHARS:
            half = MAX_OUTPUT_CHARS // 2
            output = output[:half] + f"\n\n... [{len(output) - MAX_OUTPUT_CHARS} chars truncated] ...\n\n" + output[-half:]
        return output
    except subprocess.TimeoutExpired:
        return f"Error: Command timed out after {timeout} seconds and was killed."
    except Exception as e:
        return f"Error executing command: {e}"
