#!/usr/bin/ruby
#
# Copyright 2026 ~cartwright (cartwright@tilde.club)
# Licensed under GPL2, not any later version
#

require 'net/http'
require 'json'
require 'uri'
require 'open3'
require 'fileutils'
require 'shellwords'

class MicroAgent
  class SecurityError < StandardError; end

  # Local workspace state tracking file
  STATE_FILE = './micro-agent.sav'  

  def initialize
    @current_dir = File.expand_path(Dir.pwd)
    setup_config_paths
    ensure_onboarded!
    setup_system_prompt
    load_state!
  end

  def run(user_instruction)
    @history << { role: 'user', content: user_instruction }
    save_state!
    
    loop do
      prune_context!
      
      puts "\n🤖 Thinking [Using Profile ##{@active_index}: #{@model}]..."
      response = call_llm
      
      @history << { role: 'assistant', content: response }
      save_state!
      
      parsed = parse_response(response)
      
      if parsed['thought']
        puts "🧠 Reason: #{parsed['thought']}"
      end

      if parsed['tool'] && parsed['tool'] != 'complete'
        puts "🛠️  Executing: #{parsed['tool']} with args: #{parsed['args']}"
        tool_result = dispatch_tool(parsed['tool'], parsed['args'])
        
        @history << { 
          role: 'user', 
          content: "TOOL RESULT:\n#{tool_result.to_json}\nNext steps? If fixed/done, use tool: complete." 
        }
        save_state!
      else
        puts "✅ Objective reached or agent stopped."
        break
      end
    end
  end

  # --- CLI Slash Command Manager ---
  def handle_slash_command(command_line)
    tokens = command_line.strip.split(/\s+/)
    primary = tokens[0].downcase

    case primary
    when '/help'
      print_help_menu
    when '/btw'
      question = command_line.sub(/^\/btw\s*/i, '').strip
      if question.empty?
        puts "❌ Error: Missing question parameter. usage: `/btw <your question>`"
      else
        execute_btw(question)
      end
    when '/api'
      sub = tokens[1]&.downcase
      case sub
      when 'add'
        interactive_add_profile
      when 'del'
        interactive_delete_profile(tokens[2])
      when 'list'
        print_profiles_list
      when 'config'
        interactive_edit_profile(tokens[2])
      when 'use'
        switch_active_profile(tokens[2])
      else
        puts "❌ Invalid /api command format. Type `/help` for options."
      end
    else
      puts "❌ Unknown slash command: #{primary}. Type `/help` for guidance."
    end
  end

  private
  
  # Set up the location for the global home directory config
  def setup_config_paths
    @config_file = File.expand_path('~/.micro-agent.conf')
  end

  def print_help_menu
    puts <<~HELP
      ================================================================================
      💻 Micro Agent Help & Commands
      ================================================================================
      /help               Display this diagnostic utility console menu
      /btw <question>     Ask an ephemeral side question without polluting history
      /exit or /quit      Gracefully snapshot session memory and close the process
      
      📊 Provider & API Target Profiling:
      /api list           Show all available backend model engine configurations
      /api add            Onboard a new vendor, local model target or credential set
      /api use #id        Instantly change your active model to target layout ID
      /api config #id     Modify or re-configure an existing profile layout block
      /api del #id        Unlink and drop a provider configuration block
      ================================================================================
    HELP
  end

  # Initial run configuration onboarding verification
  def ensure_onboarded!
    if File.exist?(@config_file)
      load_config_and_activate!
    else
      puts "================================================================================"
      puts "🚨 CRITICAL SECURITY WARNING & RISK ACKNOWLEDGMENT"
      puts "================================================================================"
      puts "Micro Agent grants an AI Engine the structural ability to modify files and"
      puts "execute terminal commands inside your local workspace."
      puts ""
      puts "An untrusted, hallucinating, or misconfigured AI model can inadvertently"
      puts "corrupt, overwrite, or delete source trees, or run damaging shell operations."
      puts ""
      puts "💡 SYSTEM ISOLATION RECOMMENDED:"
      puts "To mitigate security vulnerabilities, it is highly advised that you deploy"
      puts "and run this agent within a dedicated, restricted host user account built"
      puts "specifically for this sandbox environments WITHOUT administrative (sudo) privileges."
      puts "================================================================================"
      
      print "Do you explicitly acknowledge these structural risks and wish to continue anyway? (y/n): "
      consent = gets&.chomp&.downcase&.strip
      
      unless ['y', 'yes'].include?(consent)
        abort "❌ Initialization Aborted: Operational risks declined by the user."
      end

      puts "\n================================================================================"
      puts "⚙️  Welcome to Micro Agent! No configuration structure found at #{@config_file}"
      puts "Let's build your very first API configuration profile baseline entry."
      puts "================================================================================"

      new_profile = prompt_profile_fields({}, first_run: true)
      
      # Build baseline schema layout wrapper
      @config_wrapper = {
        'active_index' => 0,
        'profiles' => [new_profile]
      }
      
      save_global_config_file!
      puts "\n✅ Base profiling complete! Settings stored safely."
      load_config_and_activate!
    end
  end

  def load_config_and_activate!
    @config_wrapper = JSON.parse(File.read(@config_file))
    @active_index   = @config_wrapper['active_index'] || 0
    
    # Structural fallback check if array elements corrupted manually
    if @config_wrapper['profiles'].nil? || @config_wrapper['profiles'].empty?
      @config_wrapper['profiles'] = [{
        'api_url' => 'http://localhost:11434/v1/chat/completions',
        'api_key' => 'bearer-mock-key',
        'model' => 'llama3',
        'max_history_turns' => 10,
        'confirm_file_ops' => true
      }]
      @active_index = 0
      save_global_config_file!
    end

    # Keep active index target boundary validated inside bounds
    if @active_index >= @config_wrapper['profiles'].size
      @active_index = 0
      @config_wrapper['active_index'] = 0
      save_global_config_file!
    end

    active_profile = @config_wrapper['profiles'][@active_index]
    @api_url             = active_profile['api_url']
    @api_key             = active_profile['api_key']
    @model               = active_profile['model']
    @max_history_turns   = active_profile['max_history_turns'] || 10
    @confirm_file_ops    = active_profile.key?('confirm_file_ops') ? active_profile['confirm_file_ops'] : true
  rescue => e
    abort "❌ Initialization Failure: Your configuration file at #{@config_file} is corrupt: #{e.message}"
  end

  def save_global_config_file!
    FileUtils.mkdir_p(File.dirname(@config_file))
    File.write(@config_file, JSON.pretty_generate(@config_wrapper))
  end

  # Helper to guide through form filling with customizable context layers
  def prompt_profile_fields(defaults = {}, first_run: false)
    d_url = defaults['api_url'] || 'http://localhost:11434/v1/chat/completions'
    d_key = defaults['api_key'] || 'bearer-mock-key'
    d_model = defaults['model'] || 'llama3'
    d_turns = defaults['max_history_turns'] || 10
    d_confirm = defaults.key?('confirm_file_ops') ? defaults['confirm_file_ops'] : true

    print "Enter AI API Endpoint URL [default: #{d_url}]: "
    input_url = gets&.chomp&.strip
    url = input_url.empty? ? d_url : input_url

    print "Enter AI API Key [default: #{d_key}]: "
    input_key = gets&.chomp&.strip
    key = input_key.empty? ? d_key : input_key

    print "Enter AI Model Name [default: #{d_model}]: "
    input_model = gets&.chomp&.strip
    model = input_model.empty? ? d_model : input_model

    print "Enter Context Max History Turns [default: #{d_turns}]: "
    input_turns = gets&.chomp&.strip
    turns = input_turns.empty? ? d_turns : input_turns.to_i

    if first_run
      puts "\n--------------------------------------------------------------------------------"
      puts "⚠️  CRITICAL SECURITY NOTICE: FILE OPERATION AUTOMATION & AGENT SANDBOXING RISKS"
      puts "--------------------------------------------------------------------------------"
      puts "Micro Agent can intercept internal 'read_file', 'write_file', 'patch_file', and 'delete_file'"
      puts "tools to prompt you for manual (y/n) approval before touching your disk."
      puts ""
      puts "If you disable confirmation prompts, the agent will edit or erase your"
      puts "workspace files automatically without checking with you first."
      puts ""
      puts "🔴 ATTENTION: DISABLING THIS DOES NOT PROTECT YOUR SYSTEM anyway."
      puts "Even if you require confirmation for core file operations, an untrusted or"
      puts "hallucinating model can still use the 'execute' tool to create malicious"
      puts "programs (like an unverified Python script, Bash utility, or Makefile) that"
      puts "directly target and mess with YOUR files, running them silently behind the scenes."
      puts "--------------------------------------------------------------------------------"
    end

    confirm_ops = d_confirm
    loop do
      print "Enable interactive confirmation prompts for all file operations? (y/n) [default: #{d_confirm ? 'y' : 'n'}]: "
      input_confirm = gets&.chomp&.downcase&.strip
      if input_confirm.empty?
        confirm_ops = d_confirm
        break
      elsif ['y', 'yes'].include?(input_confirm)
        confirm_ops = true
        break
      elsif ['n', 'no'].include?(input_confirm)
        confirm_ops = false
        puts "\n⚠️  Risk acknowledged. Automated file operations are active for this profile."
        break
      else
        puts "Invalid option. Please choose 'y' or 'n'."
      end
    end

    {
      'api_url' => url,
      'api_key' => key,
      'model' => model,
      'max_history_turns' => turns,
      'confirm_file_ops' => confirm_ops
    }
  end

  def print_profiles_list
    puts "\n================================================================================"
    puts "📋 Registered Provider & Engine Profiles"
    puts "================================================================================"
    @config_wrapper['profiles'].each_with_index do |prof, idx|
      marker = (idx == @active_index) ? "⭐️ [ACTIVE]" : "   "
      puts "#{marker} ID: #{idx} | Model: #{prof['model']} | URL: #{prof['api_url']}"
      puts "         File Guarding Rails: #{prof['confirm_file_ops'] ? 'ENABLED (Ask First)' : 'DISABLED (Automated)'}"
      puts "--------------------------------------------------------------------------------"
    end
    puts "👉 Run `/api use #ID` to pivot active intelligence context engines."
  end

  def interactive_add_profile
    puts "\n➕ Add New API Engine Configuration Profile"
    puts "--------------------------------------------------------------------------------"
    new_prof = prompt_profile_fields({}, first_run: false)
    @config_wrapper['profiles'] << new_prof
    save_global_config_file!
    puts "✅ Profile appended successfully at index Position ID: #{@config_wrapper['profiles'].size - 1}"
    print_profiles_list
  end

  def interactive_edit_profile(id_string)
    if id_string.nil? || id_string.strip.empty?
      puts "❌ Error: Missing configuration index ID parameter. usage: `/api config #number`"
      return
    end

    idx = id_string.delete('#').to_i
    if idx < 0 || idx >= @config_wrapper['profiles'].size
      puts "❌ Error: Invalid index position target boundary ID requested: #{idx}"
      return
    end

    puts "\n🛠️  Modifying Existing Profile Layout Target Block ID: #{idx}"
    puts "--------------------------------------------------------------------------------"
    current_prof = @config_wrapper['profiles'][idx]
    updated_prof = prompt_profile_fields(current_prof, first_run: false)
    
    @config_wrapper['profiles'][idx] = updated_prof
    save_global_config_file!
    puts "✅ Profile ID #{idx} updated successfully!"
    load_config_and_activate!
  end

  def interactive_delete_profile(id_string)
    if id_string.nil? || id_string.strip.empty?
      puts "❌ Error: Missing target deletion tracking index ID parameter. usage: `/api del #number`"
      return
    end

    idx = id_string.delete('#').to_i
    if idx < 0 || idx >= @config_wrapper['profiles'].size
      puts "❌ Error: Target configuration index not found: #{idx}"
      return
    end

    if @config_wrapper['profiles'].size <= 1
      puts "❌ Protection Guard: Cannot clear last remaining profile. Onboard an alternative first."
      return
    end

    @config_wrapper['profiles'].delete_at(idx)
    
    # Balance index pointers cleanly after an item drops out of array alignment
    if @active_index == idx
      @active_index = 0
    elsif @active_index > idx
      @active_index -= 1
    end
    
    @config_wrapper['active_index'] = @active_index
    save_global_config_file!
    puts "🗑️  Profile ID #{idx} scrubbed and dropped out of registry mapping entries."
    load_config_and_activate!
    print_profiles_list
  end

  def switch_active_profile(id_string)
    if id_string.nil? || id_string.strip.empty?
      puts "❌ Error: Missing targeting index ID parameter. usage: `/api use #number`"
      return
    end

    idx = id_string.delete('#').to_i
    if idx < 0 || idx >= @config_wrapper['profiles'].size
      puts "❌ Error: Target reference ID does not resolve to an active profile array allocation: #{idx}"
      return
    end

    @config_wrapper['active_index'] = idx
    save_global_config_file!
    load_config_and_activate!
    puts "🎯 Switched active profile targeting to Context ID Engine Node: [#{idx}: #{@model}]"
  end

  def execute_btw(question)
    puts "\n⏳ Fetching side-answer [By The Way]..."
    
    # Fork conversation snapshot to preserve state context entirely
    forked_history = @history.dup
    forked_history << { role: 'user', content: question }
    
    # Explicitly instruct the model to skip formatting constraints and tools
    btw_system_prompt = <<~SYSTEM
      You are an AI assistant answering a quick side question within an active coding session context.
      Review the historical background context to inform your response, but answer the user's question directly using standard conversational prose or Markdown.
      CRITICAL: Do NOT execute any tool calls. Do NOT format your output into JSON syntax blocks.
    SYSTEM

    uri = URI.parse(@api_url)
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = (uri.scheme == 'https')

    request = Net::HTTP::Post.new(uri.path, {
      'Content-Type' => 'application/json',
      'Authorization' => "Bearer #{@api_key}"
    })

    payload = {
      model: @model,
      messages: [{ role: 'system', content: btw_system_prompt }] + forked_history,
      temperature: 0.2
    }
    
    request.body = payload.to_json
    response = http.request(request)
    
    if response.code.to_i == 200
      answer = JSON.parse(response.body).dig('choices', 0, 'message', 'content').strip
      puts "\n💡 [BTW Response]:"
      puts "--------------------------------------------------------------------------------"
      puts answer
      puts "--------------------------------------------------------------------------------"
    else
      puts "❌ API Error during /btw: #{response.body}"
    end
  rescue => e
    puts "❌ Error executing /btw: #{e.message}"
  end

  def setup_system_prompt
    @system_prompt = <<~SYSTEM
      You are a minimalist, highly efficient AI coding agent. You operate step-by-step.
      Before every action, you MUST reason about:
      1. What you are trying to accomplish.
      2. What could go wrong (compilation errors, edge cases, missing files).

      You interact with the system ONLY by returning a strict, raw JSON object. Do not wrap it in markdown blocks.
      
      Format:
      {
        "thought": "Your reasoning about the step and risks here",
        "tool": "tool_name",
        "args": { ... tool dependent keys ... }
      }

      Available tools:
      - 'read_file':   { "path": "filename" }
      - 'write_file':  { "path": "filename", "content": "..." }
      - 'patch_file':  { "path": "filename", "search": "exact unique text block to find", "replace": "new structural text block to put in its place" }
      - 'delete_file': { "path": "filename" }
      - 'list_dir':    {}
      - 'execute':     { "command": "gcc main.c / make / python script.py etc" }
      - 'complete':    {}

      CRITICAL CONSTRAINTS:
      1. Use 'patch_file' instead of 'write_file' whenever modifying existing files to minimize token footprint. Ensure the 'search' block provides enough distinct lines to match uniquely.
      2. You can only touch or see files within the current directory tree. No path escaping via '../'.
    SYSTEM
  end

  def load_state!
    if File.exist?(STATE_FILE)
      begin
        @history = JSON.parse(File.read(STATE_FILE))
        puts "💾 Restored active workspace state from #{STATE_FILE} (#{@history.size} trace events retained)."
      rescue => e
        puts "⚠️ Warning: State file corrupted or unreadable (#{e.message}). Initializing clean slate."
        @history = []
      end
    else
      @history = []
    end
  end

  def save_state!
    File.write(STATE_FILE, JSON.pretty_generate(@history))
  rescue => e
    puts "⚠️ State Save Error: Unable to sync context history to disk: #{e.message}"
  end

  def call_llm
    uri = URI.parse(@api_url)
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = (uri.scheme == 'https')

    request = Net::HTTP::Post.new(uri.path, {
      'Content-Type' => 'application/json',
      'Authorization' => "Bearer #{@api_key}"
    })

    payload = {
      model: @model,
      messages: [{ role: 'system', content: @system_prompt }] + @history,
      temperature: 0.2
    }
    
    request.body = payload.to_json
    response = http.request(request)
    
    raise "API Error: #{response.body}" unless response.code.to_i == 200

    JSON.parse(response.body).dig('choices', 0, 'message', 'content').strip
  rescue => e
    "{\"thought\": \"Failed to connect to LLM: #{e.message}\", \"tool\": \"complete\"}"
  end

  def parse_response(response_text)
    clean_text = response_text.gsub(/```json|
```/, '').strip

    if clean_text.count('"').odd? && !clean_text.end_with?('"')
      clean_text << '"'
    end

    open_braces = clean_text.count('{')
    close_braces = clean_text.count('}')
    if open_braces > close_braces
      clean_text << '}' * (open_braces - close_braces)
    end

    JSON.parse(clean_text)
  rescue JSON::ParserError
    { "thought" => "Failed to parse model output as JSON. Raw string: #{response_text}", "tool" => "complete" }
  end

  def prune_context!
    if @history.size > @max_history_turns
      puts "🧹 Pruning oldest entries from conversation history to preserve context window..."
      @history = [@history.first] + @history.slice(-@max_history_turns..-1)
      save_state!
    end
  end

  def authorize_file_operation?(operation, target_path)
    return true unless @confirm_file_ops
    
    print "⚠️  [Security Gate] Grant agent permission to '#{operation}' on '#{target_path}'? (y/n): "
    choice = gets&.chomp&.downcase&.strip
    choice == 'y' || choice == 'yes'
  end

  def dispatch_tool(tool_name, args)
    if ['read_file', 'write_file', 'patch_file', 'delete_file'].include?(tool_name)
      unless authorize_file_operation?(tool_name, args['path'])
        return { error: "Permission Denied: Host user intercepted and explicitly blocked the #{tool_name} operation." }
      end
    end

    case tool_name
    when 'read_file'
      path = secure_path!(args['path'])
      File.exist?(path) ? { content: File.read(path) } : { error: "File not found" }
    
    when 'write_file'
      path = secure_path!(args['path'])
      FileUtils.mkdir_p(File.dirname(path))
      File.write(path, args['content'])
      { status: "Success writing to #{args['path']}" }
    
    when 'patch_file'
      path = secure_path!(args['path'])
      unless File.exist?(path)
        return { error: "Patch target error: File not found at '#{args['path']}'." }
      end

      search_str  = args['search']
      replace_str = args['replace']

      if search_str.nil? || search_str.empty?
        return { error: "Patch target error: Search parameter block is empty or missing." }
      end

      file_contents = File.read(path)
      
      unless file_contents.include?(search_str)
        return { error: "Patch target error: The exact string block specified in the 'search' argument was not found in the file." }
      end

      if file_contents.scan(search_str).size > 1
        return { error: "Patch target error: The search block provided is ambiguous and matched multiple locations inside the file. Provide more lines of surrounding code context to guarantee uniqueness." }
      end

      file_contents.sub!(search_str, replace_str)
      File.write(path, file_contents)
      { status: "Success patching segment within #{args['path']}" }
    
    when 'delete_file'
      path = secure_path!(args['path'])
      if File.exist?(path)
        File.delete(path)
        { status: "Deleted #{args['path']}" }
      else
        { error: "File not found" }
      end

    when 'list_dir'
      { files: Dir.glob("**/*").reject { |f| File.directory?(f) } }

    when 'execute'
      execute_command(args['command'])

    else
      { error: "Unknown tool: #{tool_name}" }
    end
  rescue SecurityError => e
    { error: "Security Violation: #{e.message}" }
  rescue => e
    { error: "Tool Execution Failed: #{e.message}" }
  end

  def secure_path!(relative_path)
    absolute_path = File.expand_path(relative_path, @current_dir)
    existing_target = File.exist?(absolute_path) ? absolute_path : File.dirname(absolute_path)
    real_target = File.realpath(existing_target)
    
    root_prefix = @current_dir.end_with?(File::SEPARATOR) ? @current_dir : @current_dir + File::SEPARATOR

    unless real_target.start_with?(root_prefix) || real_target == @current_dir
      raise SecurityError, "Directory traversal via symlink/path blocked: #{relative_path}"
    end
    
    if File.symlink?(absolute_path)
      raise SecurityError, "Direct access or creation of symlinks is prohibited: #{relative_path}"
    end

    absolute_path
  end

  def execute_command(command)
    if command.include?(';') || command.include?('&&') || command.include?('|') || command.include?('`')
      return { error: "Security Violation: Command chaining tokens (&&, ;, |, `) are forbidden." }
    end

    args = Shellwords.shellsplit(command)
    return { error: "Empty command string provided." } if args.empty?
    
    forbidden_binaries = ['ln', 'chroot', 'chmod', 'sudo', 'su']
    if forbidden_binaries.include?(args.first.downcase)
      return { error: "Security Violation: Use of binary '#{args.first}' is strictly forbidden." }
    end

    if ['python', 'python3', 'ruby', 'perl', 'bash', 'sh'].include?(args.first.downcase)
      if args.any? { |a| a.start_with?('-c') || a.start_with?('-e') }
        return { error: "Security Violation: Inline script evaluation flags (-c, -e) are blocked." }
      end
    end

    args.each do |arg|
      if arg.include?('..')
        return { error: "Security Violation: Path traversal characters '..' detected in command arguments." }
      end

      if arg.start_with?('/') || arg.start_with?('~')
        begin
          secure_path!(arg)
        rescue SecurityError
          return { error: "Security Violation: Command argument targets a path outside the working directory sandbox." }
        end
      end
    end

    stdout, stderr, status = Open3.capture3(*args, chdir: @current_dir)
    
    {
      stdout: stdout,
      stderr: stderr,
      exit_code: status.exitstatus
    }
  rescue ArgumentError => e
    { error: "Invalid command format: #{e.message}" }
  rescue Errno::ENOENT
    { error: "Command binary not found on system: '#{args.first}'" }
  end
end

if __FILE__ == $0
  agent = MicroAgent.new

  puts "💻 Welcome to Micro Agent"
  puts "Type your instruction to begin. Use /help to see a list of commands, or /exit to exit.\n"
  
  loop do
    print "\nmicro-agent > "
    instruction = gets&.chomp
    
    if instruction.nil?
      puts "\nGoodbye! 👋"
      break
    end
    
    cleaned = instruction.strip
    next if cleaned.empty?
    
    if ['/exit', '/quit'].include?(cleaned.downcase)
      puts "Goodbye! 👋"
      break
    elsif cleaned.start_with?('/')
      # Route structural slash commands directly to the agent runtime state interface
      agent.handle_slash_command(cleaned)
    else
      # Regular chat string targeting the active LLM context loop
      agent.run(cleaned)
    end
  end
end
