#!/usr/bin/env python3

#!/usr/bin/env python3
# file manipulation in 'workspace'

import sys
import os
import json
import time
from pathlib import Path
import requests

# ===========================
# configuration
# ===========================
API_KEY = 'XXX'
API_URL = 'https://litellm.s.studiumdigitale.uni-frankfurt.de/v1/chat/completions'
MODEL   = 'openai-gpt-oss-120b'

BASE_DIR = Path(__file__).parent.resolve()
HISTORY_FILE = BASE_DIR / 'chat_history.jsonl'
WORKSPACE = BASE_DIR / 'workspace'

# Ensure workspace exists
WORKSPACE.mkdir(parents=True, exist_ok=True)

# ===========================
# Path handling
# ===========================
def safe_path(path_str: str = '') -> Path:
    """
    Resolves a relative path within the workspace sandbox safely,
    preventing directory traversal attacks.
    """
    root = WORKSPACE.resolve()
    path_str = path_str.strip().lstrip('/')
    
    if not path_str:
        return root
        
    candidate = (root / path_str).resolve()
    
    # Check if the path resides inside the workspace root
    if root not in candidate.parents and candidate != root:
        raise PermissionError('Access denied')
        
    return candidate

# ===========================
# Tool storage
# ===========================
TOOL_REGISTRY = {}       # all tool definitions
TOOL_HISTORY  = []       # of current session

# ===========================
# Tool: listing files
# ===========================
TOOL_REGISTRY["list_files"] = {\
  'type'    : 'function',
  'function': { 'name': 'list_files',
                'description': 'Recursively list files and directories in the workspace. Returns a formatted list showing [DIR] for directories and [FILE] for files.',
          'parameters': { 'type': 'object',
                'properties': { 'path': { 'type': 'string',
                'description': 'Optional subdirectory path. Leave empty for workspace root.'
                                              }
                                  }
                        }
              }
                               }

def list_files(path: str = '') -> str:
    try:
        target_dir = safe_path(path)
        if not target_dir.is_dir():
            return 'ERROR: Not a directory'
            
        root = WORKSPACE.resolve()
        result = []
        
# Recursive globbing to match PHP's RecursiveIteratorIterator
        for item in target_dir.rglob('*'):
            # Calculate relative path from workspace root
            relative = item.relative_to(root)
            if item.is_dir():
                result.append(f'[DIR]  {relative}')
            else:
                result.append(f'[FILE] {relative}')
                
# Natural/Case-insensitive sorting approximation
        result.sort(key=lambda s: s.lower())
        
        if not result:
            return '(empty workspace)'
            
        return '\n'.join(result)
    except Exception as e:
        return f'ERROR: {str(e)}'

# ===========================
# Tool: read file
# ===========================
TOOL_REGISTRY["read_file"]  = {\
  'type'    : 'function',
  'function': { 'name': 'read_file',
                'description': 'Read the contents of a text file. Returns the full content or a truncated version for large files.',
          'parameters': { 'type': 'object',
                    'properties': { 'path': { 'type': 'string',
                               'description': 'Path to the file to read.'
                                              }
                                  },
                      'required': ['path']
                        }
              }
                               }

def read_file(path: str) -> str:
    try:
        file_path = safe_path(path)
        if not file_path.exists():
            return 'ERROR: File not found'
        if not file_path.is_file():
            return 'ERROR: Not a file'
            
        content = file_path.read_text(encoding='utf-8', errors='replace')
        
        if len(content) > 100000:
            return content[:100000] + "\n\n[TRUNCATED]"
            
        return content
    except Exception as e:
        return f'ERROR: {str(e)}'

# ===========================
# Tool: write file
# ===========================
TOOL_REGISTRY["write_file"] = {
            'type': 'function',
            'function': {
                'name': 'write_file',
                'description': 'Write content to a file. Creates the file if it does not exist, or overwrites it if it does.',
                'parameters': {
                    'type': 'object',
                    'properties': {
                        'path': {'type': 'string', 'description': 'Path where the file should be written'},
                        'content': {'type': 'string', 'description': 'Content to write to the file'}
                    },
                    'required': ['path', 'content']
                }
            }
        }

def write_file(path: str, content: str) -> str:
    try:
        file_path = safe_path(path)

        # Ensure parent directories exist
        file_path.parent.mkdir(parents=True, exist_ok=True)
        file_path.write_text(content, encoding='utf-8')
        return f'{"Created" if not file_path.exists() else "Overwrote"} file: {path}'
    except Exception as e:
        return f'ERROR: {str(e)}'

# ===========================
# Tool: append to file
# ===========================
TOOL_REGISTRY["append_file"]  = {\
            'type': 'function',
            'function': {
                'name': 'append_file',
                'description': 'Append text to an existing file. Creates the file if it does not exist.',
                'parameters': { 
                    'type': 'object',
                    'properties': {
                        'path': {'type': 'string', 'description': 'Path to the file to append to'},
                        'content': {'type': 'string', 'description': 'Content to append to the file'}
                    },
                    'required': ['path', 'content']
                }
            }
        }

def append_file(path: str, content: str) -> str:
    try:
        file_path = safe_path(path)
        # Ensure parent directories exist
        file_path.parent.mkdir(parents=True, exist_ok=True)
        
        with open(file_path, 'a', encoding='utf-8') as f:
            f.write(content)
            
        return f'Appended to file: {path}'
    except Exception as e:
        return f'ERROR: {str(e)}'

# ===========================
# Tool-handlers (dispatch table pattern)
# Mapping tool names (strings) to handler functions (lambdas)
# Avoids exposing other functions to the LLM
# ===========================
TOOL_HANDLERS = {
    'list_files':   lambda args: list_files(args.get('path', '')),
    'read_file':    lambda args: read_file(args.get('path')),
    'write_file':   lambda args: write_file(args.get('path'), 
                           args.get('content')),
    'append_file':  lambda args: append_file(args.get('path'), 
                           args.get('content')),
                }

# ===========================
# chat history: loading
# ===========================
def load_history(history_path: Path) -> list:
    if not history_path.exists():
        return []
        
    messages = []
    try:
        with open(history_path, 'r', encoding='utf-8') as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    messages.append(json.loads(line))
                except json.JSONDecodeError:
                    continue
    except Exception:
        return []
    return messages

# ===========================
# chat history: saving 
# ===========================
def save_history_message(history_path: Path, role: str, 
                         content: str) -> None:
    entry = {'role': role, 'content': content}
    with open(history_path, 'a', encoding='utf-8') as f:
        f.write(json.dumps(entry, ensure_ascii=False) + '\n')

# ===========================
# model call: retrying
# ===========================
def call_model_with_retry(api_url, api_key, model, messages, max_retries=3):
    for i in range(max_retries):
        try:
            return call_model(api_url, api_key, model, messages)
        except Exception as e:
            if i == max_retries - 1:
                raise e
            time.sleep(1)

# ===========================
# model call: request  
# ===========================
def call_model(api_url: str, api_key: str, model: str, 
               messages: list) -> dict:
    tools = [TOOL_REGISTRY["list_files"],
             TOOL_REGISTRY["read_file"],
             TOOL_REGISTRY["write_file"],
             TOOL_REGISTRY["append_file"] ]

    payload = {
        'model': model,
        'messages': messages,
        'tools': tools,
        'tool_choice': 'auto',
        'temperature': 0.2
    }

    headers = {'Content-Type': 'application/json'}
    if api_key:
        headers['Authorization'] = f'Bearer {api_key}'

    try:
        response = requests.post(api_url, json=payload, headers=headers, timeout=60)
        response.raise_for_status()
        return response.json()
    except requests.exceptions.RequestException as e:
        # Debugging fallback matching PHP implementation
        print("\nDEBUG: Request failed or invalid response.")
        if 'response' in locals() and response is not None:
            print(f"DEBUG: Raw response (first 500 chars):\n{response.text[:500]}")
        raise Exception(f"API Error: {str(e)}")

# ===========================
# Agent loop
# ===========================
def run_agent(api_url: str, api_key: str, model: str, 
              messages: list) -> str:
    """looping as a long a the LLM call new tools"""
    while True:
        response = call_model_with_retry(api_url, api_key, model, messages)

        if 'choices' not in response or not response['choices']:
            print("\nDEBUG: Response structure:")
            print(response.keys())
            raise Exception('Unexpected API response structure')

        message = response['choices'][0]['message']
        tool_calls = message.get('tool_calls')

        if not tool_calls:        # LLM finished; exit run_agent()
            return message.get('content') or ''

# Append assistant response containing tool calls to conversation history
        messages.append(message)

        for tool_call in tool_calls:
            name = tool_call['function']['name']
#           print("# in run_agent() tool name : ", name, "  \n")
            TOOL_HISTORY.append(name)                # update history
            try:
                args = json.loads(tool_call['function']['arguments'])
            except (json.JSONDecodeError, TypeError):
                args = {}

            try:
              handler = TOOL_HANDLERS.get(name)      # call this tool
              result = handler(args) if handler else 'ERROR: Unknown tool'
            except Exception as e:
              result = f'ERROR: {str(e)}'

# Format the tool execution result role back into the array context
            messages.append({'role': 'tool',
                     'tool_call_id': tool_call['id'],
                             'name': name,
                          'content': result})

# ===========================
# System prompt
# ===========================
def get_sys_prompt():
  available_tools = [tool['function']['name']\
                 for tool in TOOL_REGISTRY.values()]
  SP = {
    'role': 'system',
    'content': (
        '# Workspace File Assistant\n\n'
        'You are an automated file operator. Your job is to execute file operations using the available tools.\n\n'
        '## Critical Rules\n'
        '- **MUST** use tools for all file operations\n'
        '- **NEVER** invent file contents\n'
        '- **ALWAYS** read before modifying\n'
        '- **NEVER** answer from prior knowledge\n'
        '- **NEVER** perform operations outside the given workspace\n\n'
        '## Available Tools\n'
        f'- {", ".join(available_tools)}\n\n'
        '## Paths\n'
        '- All paths are relative to the workspace root.'
               )
       }
  print("# ===========")
  print(SP['content'])
  print("# ===========\n")
  return SP

# ===========================
# Main
# ===========================
def main():
    print(f"\nMinimal File Agent")
    print(f"Workspace: {WORKSPACE}")
    print("Type 'exit' to quit\n")

    with open(HISTORY_FILE, "w") as f:   # empty hitory file
       pass
    history = load_history(HISTORY_FILE) # start fresh

    system_prompt = get_sys_prompt()
    old_nTools = 0

    while True:
        try:
            nTools  = len(TOOL_HISTORY)
            cT      = TOOL_HISTORY[old_nTools:nTools] if nTools>0 else ""
            old_nTools = nTools
#           print("# old_nTools ", old_nTools)
#           print("#     nTools ",     nTools)
            print(f"\r\033[K[{nTools}] {cT} ", end="", flush=True)
            user_input = input('> ').strip()
#           print(user_input)
        except (KeyboardInterrupt, EOFError):
            print("\nBye!")
            sys.exit(0)

        if not user_input:
            continue

        if user_input.lower() in ['exit', 'quit']:
            print("Bye!")
            sys.exit(0)

        save_history_message(HISTORY_FILE, 'user', user_input)

# Build context list matching structural constraints of the PHP script
        messages = [system_prompt] + history + [{'role': 'user', 'content': user_input}]

        try:
            reply = run_agent(API_URL, API_KEY, MODEL, messages)
            
            print(f"\n{reply}\n")
            save_history_message(HISTORY_FILE, 'assistant', reply)

# Keep runtime user history synchronized
            history.append({'role': 'user', 'content': user_input})
            history.append({'role': 'assistant', 'content': reply})

        except Exception as e:
            print(f"\nERROR: {str(e)}\n")

if __name__ == '__main__':
    main()
