"""Helpful tools for running cli commands and reading, modifying, and creating files in python. This is used primarily for AI's in tool loops for automating tasks involving the filesystem."""

# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/12_tools.ipynb.

# %% auto #0
__all__ = ['valid_paths', 'explain_exc', 'ensure', 'valid_path', 'run_cmd', 'rg', 'sed', 'view', 'create', 'insert',
           'str_replace', 'strs_replace', 'replace_lines', 'move_lines', 'get_callable']

# %% ../nbs/12_tools.ipynb #578246d2
from .xdg import *
from .imports import *
from .xtras import truncstr
from shlex import split
from subprocess import run, DEVNULL

# %% ../nbs/12_tools.ipynb #7603dedb
def explain_exc(task=''):
    """Convert an current exception to  an LLM friendly error message."""
    try: raise sys.exc_info()[1]
    except (AssertionError, ZeroDivisionError, ValueError, FileNotFoundError) as e:
        return f"Error: {e}"
    except Exception as e: return f"Error {task}: {repr(e)}"


# %% ../nbs/12_tools.ipynb #b8823d07
def ensure(b: bool, msg:str=""):
    "Works like assert b, msg but raise ValueError and is not disabled when run with python -O"
    if not b: raise ValueError(msg)


# %% ../nbs/12_tools.ipynb #6afaf015
def _load_valid_paths():
    cfg = xdg_config_home() / 'fc_tools_paths'
    base = ['.', '/tmp']
    if not cfg.exists(): return base
    return base + cfg.read_text().split()

valid_paths = _load_valid_paths()

# %% ../nbs/12_tools.ipynb #e4288129
def valid_path(path:str, must_exist:bool=True, chk_perms:bool=True) -> Path:
    'Return expanded/resolved Path, raising FileNotFoundError if must_exist and missing'
    p = Path(path).expanduser().resolve()
    vpaths = [Path(vp).expanduser().resolve() for vp in valid_paths]
    if chk_perms and not any(p == vp or vp in p.parents for vp in vpaths): raise PermissionError(f'Path not in valid_paths: {p}')
    if must_exist and not p.exists(): raise FileNotFoundError(f'File not found: {p}')
    return p

# %% ../nbs/12_tools.ipynb #1182336d
def run_cmd(
    cmd:str, # The command name to run
    argstr:str='', # All args to the command, will be split with shlex
    disallow_re:str=None, # optional regex which, if matched on argstr, will disallow the command
    allow_re:str=None # optional regex which, if not matched on argstr, will disallow the command
):
    "Run `cmd` passing split `argstr`, optionally checking for allowed argstr"
    try:
        ensure(not (disallow_re and re.search(disallow_re, argstr)), 'args disallowed')
        ensure(not (allow_re    and re.search(   allow_re, argstr)), 'args not allowed')
        argstr = argstr.replace('\\', '\\\\')
        args = [str(Path(a).expanduser()) if a.startswith('~') else a for a in split(argstr, posix=True)]
        outp = run([cmd] + args, text=True, stdin=DEVNULL, capture_output=True)
    except: return explain_exc(f'running cmd')
    res = outp.stdout
    if res and outp.stderr: res += '\n'
    return res + outp.stderr

# %% ../nbs/12_tools.ipynb #eb253a39
@llmtool
def rg(
    argstr:str, # All args to the command, will be split with shlex
    disallow_re:str=None, # optional regex which, if matched on argstr, will disallow the command
    allow_re:str=None # optional regex which, if not matched on argstr, will disallow the command
):
    "Run the `rg` command with the args in `argstr`"
    return run_cmd('rg', '-n '+argstr, disallow_re=disallow_re, allow_re=allow_re)

# %% ../nbs/12_tools.ipynb #09de7b32
@llmtool
def sed(
    argstr:str, # All args to the command, will be split with shlex
    disallow_re:str=None, # optional regex which, if matched on argstr, will disallow the command
    allow_re:str=None # optional regex which, if not matched on argstr, will disallow the command
):
    "Run the `sed` command with the args in `argstr` (e.g for reading a section of a file)"
    return run_cmd('sed', argstr, allow_re=allow_re, disallow_re=disallow_re)

# %% ../nbs/12_tools.ipynb #864b213a
def _fmt_path(f, p, skip_folders=()):
    'Format path with emoji for dirs/symlinks or size for files'
    parts = f.relative_to(p).parts
    if any(part.startswith('.') for part in parts): return None
    if any(part in skip_folders for part in parts): return None
    if f.is_symlink(): return f'{f} 🔗'
    if f.is_dir(): return f'{f} 📁'
    return f'{f} ({f.stat().st_size/1024:.1f}k)'

# %% ../nbs/12_tools.ipynb #5dd1aaf3
@llmtool
def view(
    path:str, # Path to directory or file to view
    view_range:tuple[int,int]=None, # Optional 1-indexed (start, end) line range for files, end=-1 for EOF. Do NOT use unless it's known that the file is too big to keep in context—simply view the WHOLE file when possible
    nums:bool=False, # Whether to show line numbers
    skip_folders:tuple[str,...]=('_proc','__pycache__') # Folder names to skip when listing directories
):
    'View directory or file contents with optional line range and numbers'
    try:
        p = valid_path(path, chk_perms=False)
        header = None
        if p.is_dir():
            lines = [s for f in p.glob('**/*') if (s := _fmt_path(f, p, skip_folders))]
            header = f'Directory contents of {p}:'
        else: lines = p.read_text().splitlines()
        s, e = 1, len(lines)
        if view_range:
            s,e = view_range
            ensure(1<=s<=len(lines), f'Invalid start line {s}')
            ensure(e==-1 or s<=e<=len(lines), f'Invalid end line {e}')
            lines = lines[s-1:None if e==-1 else e]
        if nums: lines = [f'{i+s:6d} │ {l}' for i, l in enumerate(lines)]
        content = '\n'.join(lines)
        return f'{header}\n{content}' if header else content
    except: return explain_exc('viewing')

# %% ../nbs/12_tools.ipynb #36f58e38
@llmtool
def create(
    path: str, # Path where the new file should be created
    file_text: str, # Content to write to the file
    overwrite:bool=False # Whether to overwrite existing files
) -> str:
    'Creates a new file with the given content at the specified path'
    try:
        p = valid_path(path, must_exist=False)
        if p.exists():
            if not overwrite: return f'Error: File already exists: {p}'
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(file_text)
        return f'Created file {p}.'
    except: return explain_exc('creating file')

# %% ../nbs/12_tools.ipynb #434147ef
@llmtool
def insert(
    path: str, # Path to the file to modify
    insert_line: int, # Line number where to insert (0-based indexing)
    new_str: str # Text to insert at the specified line
) -> str:
    'Insert new_str at specified line number'
    try:
        p = valid_path(path)
        content = p.read_text().splitlines()
        ensure(0<=insert_line<=len(content), f'Invalid line number {insert_line}')
        content.insert(insert_line, new_str)
        new_content = '\n'.join(content)
        p.write_text(new_content)
        return f'Inserted text at line {insert_line} in {p}'
    except: return explain_exc('inserting text')

# %% ../nbs/12_tools.ipynb #9272ff7d
@llmtool
def str_replace(
    path: str, # Path to the file to modify
    old_str: str, # Text to find and replace
    new_str: str # Text to replace with
) -> str:
    'Replace first occurrence of old_str with new_str in file'
    try:
        p = valid_path(path)
        content = p.read_text()
        count = content.count(old_str)
        if count == 0: return f'Error: Text "{truncstr(old_str, 10)}" not found in file'
        if count > 1: return f'Error: Multiple matches found ({count}) of "{truncstr(old_str, 10)}"'
        new_content = content.replace(old_str, new_str, 1)
        p.write_text(new_content)
        return f'Replaced text in {p}'
    except: return explain_exc('replacing text')

# %% ../nbs/12_tools.ipynb #eb907119
@llmtool
def strs_replace(
    path:str, # Path to the file to modify
    old_strs:list[str], # List of strings to find and replace
    new_strs:list[str], # List of replacement strings (must match length of old_strs)
):
    "Replace for each str pair in old_strs,new_strs"
    res = [str_replace(path, old, new) for (old,new) in zip(old_strs,new_strs)]
    return 'Results for each replacement:\n' + '; '.join(res)

# %% ../nbs/12_tools.ipynb #94dd09ed
@llmtool
def replace_lines(
    path:str, # Path to the file to modify
    start_line:int, # Starting line number to replace (1-based indexing)
    end_line:int, # Ending line number to replace (1-based indexing, inclusive)
    new_content:str, # New content to replace the specified lines
):
    "Replace lines in file using start and end line-numbers (index starting at 1)"
    try:
        p = valid_path(path)
        content = p.readlines()
        if not new_content.endswith('\n'): new_content+='\n'
        content[start_line-1:end_line] = [new_content]
        p.write_text(''.join(content))
        return f"Replaced lines {start_line} to {end_line}."
    except: return explain_exc('replacing lines')

# %% ../nbs/12_tools.ipynb #b136abf8
@llmtool
def move_lines(
    path: str,  # Path to the file to modify
    start_line: int,  # Starting line number to move (1-based)
    end_line: int,  # Ending line number to move (1-based, inclusive)
    dest_line: int,  # Destination line number (1-based, where lines will be inserted before)
) -> str:
    "Move lines from start_line:end_line to before dest_line"
    try:
        p = valid_path(path)
        lines = p.read_text().splitlines()
        ensure(1 <= start_line <= end_line <= len(lines), f"Invalid range {start_line}-{end_line}")
        ensure(1 <= dest_line <= len(lines) + 1, f"Invalid destination {dest_line}")
        ensure(not(start_line <= dest_line <= end_line + 1), "Destination within source range")
        
        chunk = lines[start_line-1:end_line]
        del lines[start_line-1:end_line]
        # Adjust dest if it was after the removed chunk
        if dest_line > end_line: dest_line -= len(chunk)
        lines[dest_line-1:dest_line-1] = chunk
        p.write_text('\n'.join(lines) + '\n')
        return f"Moved lines {start_line}-{end_line} to line {dest_line}"
    except: return explain_exc()

# %% ../nbs/12_tools.ipynb #fc53b116
def get_callable():
    "Return callable objects defined in caller's module"
    import inspect
    g = inspect.currentframe().f_back.f_globals
    return {
        f:o for f,o in g.items()
        if callable(o) and hasattr(o, '__module__') and o.__module__ == '__main__' and not f.startswith('_')
    }
