package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"sort"
	"strings"
	"time"
)

type Tool struct {
	Type     string       `json:"type"`
	Function ToolFunction `json:"function"`
}

type ToolFunction struct {
	Name        string         `json:"name"`
	Description string         `json:"description"`
	Parameters  map[string]any `json:"parameters"`
}

// programmerTools returns the full file toolset for the Programmer agent.
func programmerTools() []Tool { return allFileTools() }

// qaTools returns read-only file tools plus a shell command runner so QA can
// execute test suites / linters / builds for any language or framework.
func qaTools() []Tool {
	keep := map[string]bool{"read_file": true, "list_directory": true}
	all := allFileTools()
	out := make([]Tool, 0, len(keep)+1)
	for _, t := range all {
		if keep[t.Function.Name] {
			out = append(out, t)
		}
	}
	out = append(out, commandTool())
	return out
}

func commandTool() Tool {
	return Tool{
		Type: "function",
		Function: ToolFunction{
			Name: "run_command",
			Description: "Run a shell command via 'sh -c' in the project root and return combined stdout+stderr plus exit code. " +
				"Use for running test suites, linters, type-checkers, or builds — any language or framework. " +
				"Examples: 'go test ./...', 'bun test', 'npm run lint', 'php artisan test', 'composer test', 'pytest -q', 'cargo test'. " +
				"Inherits the caller's PATH and environment. The working directory starts at the project root; note that the shell itself is not path-sandboxed, so prefer commands that stay within the project.",
			Parameters: map[string]any{
				"type": "object",
				"properties": map[string]any{
					"command": map[string]any{
						"type":        "string",
						"description": "Full shell command to execute (pipes, redirects, env-prefixes supported).",
					},
					"timeout_seconds": map[string]any{
						"type":        "integer",
						"description": "Max seconds to wait. Default 60, capped at 600.",
					},
				},
				"required": []string{"command"},
			},
		},
	}
}

func allFileTools() []Tool {
	return []Tool{
		{
			Type: "function",
			Function: ToolFunction{
				Name:        "read_file",
				Description: "Read a UTF-8 text file relative to the project root. Returns the file contents.",
				Parameters: map[string]any{
					"type": "object",
					"properties": map[string]any{
						"path": map[string]any{
							"type":        "string",
							"description": "Relative path from the project root.",
						},
					},
					"required": []string{"path"},
				},
			},
		},
		{
			Type: "function",
			Function: ToolFunction{
				Name:        "create_file",
				Description: "Create a new file with the given content. Fails if the file already exists. Parent directories are created as needed.",
				Parameters: map[string]any{
					"type": "object",
					"properties": map[string]any{
						"path":    map[string]any{"type": "string", "description": "Relative path from the project root."},
						"content": map[string]any{"type": "string", "description": "Full file contents to write."},
					},
					"required": []string{"path", "content"},
				},
			},
		},
		{
			Type: "function",
			Function: ToolFunction{
				Name:        "edit_file",
				Description: "Overwrite an existing file with new content. Fails if the file does not exist.",
				Parameters: map[string]any{
					"type": "object",
					"properties": map[string]any{
						"path":    map[string]any{"type": "string", "description": "Relative path from the project root."},
						"content": map[string]any{"type": "string", "description": "Full replacement contents."},
					},
					"required": []string{"path", "content"},
				},
			},
		},
		{
			Type: "function",
			Function: ToolFunction{
				Name:        "delete_file",
				Description: "Delete a file. Fails if the path is a directory or does not exist.",
				Parameters: map[string]any{
					"type": "object",
					"properties": map[string]any{
						"path": map[string]any{"type": "string", "description": "Relative path from the project root."},
					},
					"required": []string{"path"},
				},
			},
		},
		{
			Type: "function",
			Function: ToolFunction{
				Name:        "list_directory",
				Description: "List the entries of a directory, relative to the project root. Use \".\" for the root.",
				Parameters: map[string]any{
					"type": "object",
					"properties": map[string]any{
						"path": map[string]any{"type": "string", "description": "Relative directory path. Use \".\" for the project root."},
					},
					"required": []string{"path"},
				},
			},
		},
	}
}

// runTool executes a tool call and returns a string payload for the model.
// Errors are returned as strings too so the model can react, not fatal.
func runTool(root, name, argsJSON string) string {
	var a struct {
		Path           string `json:"path"`
		Content        string `json:"content"`
		Command        string `json:"command"`
		TimeoutSeconds int    `json:"timeout_seconds"`
	}
	if argsJSON != "" {
		if err := json.Unmarshal([]byte(argsJSON), &a); err != nil {
			return fmt.Sprintf("error: invalid arguments: %v", err)
		}
	}

	switch name {
	case "read_file":
		return doRead(root, a.Path)
	case "create_file":
		return doCreate(root, a.Path, a.Content)
	case "edit_file":
		return doEdit(root, a.Path, a.Content)
	case "delete_file":
		return doDelete(root, a.Path)
	case "list_directory":
		return doList(root, a.Path)
	case "run_command":
		return doRunCommand(root, a.Command, a.TimeoutSeconds)
	default:
		return fmt.Sprintf("error: unknown tool %q", name)
	}
}

func doRead(root, p string) string {
	abs, err := resolveSafe(root, p)
	if err != nil {
		return "error: " + err.Error()
	}
	b, err := os.ReadFile(abs)
	if err != nil {
		return "error: " + err.Error()
	}
	return string(b)
}

func doCreate(root, p, content string) string {
	abs, err := resolveSafe(root, p)
	if err != nil {
		return "error: " + err.Error()
	}
	if _, err := os.Stat(abs); err == nil {
		return fmt.Sprintf("error: file already exists: %s", p)
	}
	if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
		return "error: " + err.Error()
	}
	if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
		return "error: " + err.Error()
	}
	return fmt.Sprintf("created %s (%d bytes)", p, len(content))
}

func doEdit(root, p, content string) string {
	abs, err := resolveSafe(root, p)
	if err != nil {
		return "error: " + err.Error()
	}
	info, err := os.Stat(abs)
	if err != nil {
		return "error: " + err.Error()
	}
	if info.IsDir() {
		return fmt.Sprintf("error: path is a directory: %s", p)
	}
	if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
		return "error: " + err.Error()
	}
	return fmt.Sprintf("updated %s (%d bytes)", p, len(content))
}

func doDelete(root, p string) string {
	abs, err := resolveSafe(root, p)
	if err != nil {
		return "error: " + err.Error()
	}
	if abs == root {
		return "error: refusing to delete project root"
	}
	info, err := os.Stat(abs)
	if err != nil {
		return "error: " + err.Error()
	}
	if info.IsDir() {
		return fmt.Sprintf("error: path is a directory: %s", p)
	}
	if err := os.Remove(abs); err != nil {
		return "error: " + err.Error()
	}
	return fmt.Sprintf("deleted %s", p)
}

const (
	defaultCommandTimeout = 60
	maxCommandTimeout     = 600
	maxCommandOutput      = 16 * 1024
)

func doRunCommand(root, command string, timeoutSec int) string {
	if strings.TrimSpace(command) == "" {
		return "error: empty command"
	}
	if timeoutSec <= 0 {
		timeoutSec = defaultCommandTimeout
	}
	if timeoutSec > maxCommandTimeout {
		timeoutSec = maxCommandTimeout
	}

	ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second)
	defer cancel()

	cmd := exec.CommandContext(ctx, "sh", "-c", command)
	cmd.Dir = root
	cmd.Env = os.Environ()

	start := time.Now()
	out, runErr := cmd.CombinedOutput()
	elapsed := time.Since(start).Round(time.Millisecond)

	truncated := false
	if len(out) > maxCommandOutput {
		out = out[:maxCommandOutput]
		truncated = true
	}

	var header string
	switch {
	case errors.Is(ctx.Err(), context.DeadlineExceeded):
		header = fmt.Sprintf("status=timeout after %ds  elapsed=%s", timeoutSec, elapsed)
	case runErr != nil:
		var ee *exec.ExitError
		if errors.As(runErr, &ee) {
			header = fmt.Sprintf("exit_code=%d  elapsed=%s", ee.ExitCode(), elapsed)
		} else {
			header = fmt.Sprintf("status=error: %v  elapsed=%s", runErr, elapsed)
		}
	default:
		header = fmt.Sprintf("exit_code=0  elapsed=%s", elapsed)
	}

	suffix := ""
	if truncated {
		suffix = fmt.Sprintf("\n[output truncated at %d bytes]", maxCommandOutput)
	}

	return fmt.Sprintf("$ %s\n%s\n--- output ---\n%s%s", command, header, string(out), suffix)
}

func doList(root, p string) string {
	if p == "" {
		p = "."
	}
	abs, err := resolveSafe(root, p)
	if err != nil {
		return "error: " + err.Error()
	}
	entries, err := os.ReadDir(abs)
	if err != nil {
		return "error: " + err.Error()
	}
	lines := make([]string, 0, len(entries))
	for _, e := range entries {
		name := e.Name()
		if e.IsDir() {
			name += "/"
		}
		lines = append(lines, name)
	}
	sort.Strings(lines)
	if len(lines) == 0 {
		return "(empty)"
	}
	return strings.Join(lines, "\n")
}
