// This file implements the blackboard tool (US-coop): a shared-file-system
// coordination primitive for the pigo coop runner (see coop/). A single pigo
// agent works on the task in $BB: its workspace lives at $BB/workspace, and it
// creates the DONE marker through this tool. The blackboard ($BB) holds
// task.md, the workspace/, and a DONE marker.
//
// Raw file tools cannot safely create the DONE marker: the agent's file tools
// are rooted at its own workspace, and a plain write could race the supervisor.
// So the blackboard is a dedicated tool with three atomic operations:
//
//	blackboard action=read [path=...]   global snapshot, or one file's contents
//	blackboard action=post file=... content=...   atomically append a message
//	blackboard action=done summary=...  atomically create the DONE marker
//
// Append and marker creation are atomic at the OS level (O_APPEND single-write
// for messages, O_CREATE|O_EXCL for DONE), so writes never interleave or
// clobber each other. Every path is validated against the blackboard root to
// forbid traversal.
package agenttool

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

	"github.com/smallnest/pigo/internal/agentcore"
)

// maxBlackboardMessageBytes caps a single post. The cap serves two purposes:
// keeps each append a single atomic write, and keeps the tool result from
// ballooning the context.
const maxBlackboardMessageBytes = 32 * 1024

// maxBlackboardReadBytes caps how much of one file blackboard read returns.
const maxBlackboardReadBytes = 32 * 1024

// BlackboardTool is the task blackboard for the pigo coop runner. It is
// wired into the tool set only when the BB environment variable points at a
// blackboard root (see run.SetupEnv), so ordinary pigo runs never see it.
type BlackboardTool struct {
	// Root is the blackboard root directory (the value of $BB). Must be set.
	Root string
}

// Name implements AgentTool.
func (t *BlackboardTool) Name() string { return "blackboard" }

// Description implements AgentTool.
func (t *BlackboardTool) Description() string {
	return "Read and write the task blackboard used by the pigo coop runner (see " +
		"coop/). This is the ONLY tool that may touch shared blackboard files " +
		"atomically. Actions: read (no path: global snapshot of task.md, workspace " +
		"listing, DONE state; with path like \"workspace/exploit.py\": contents of " +
		"that one file); post (atomically append a progress note; file must be a " +
		"bare .md name under messages/, e.g. \"round-<ROUND>-<NAME>.md\"); done " +
		"(atomically create the DONE marker with a final delivery summary, only when " +
		"the deliverable is truly complete; fails if DONE already exists). The " +
		"blackboard root, current round and your name are available in the " +
		"environment as BB, ROUND, NAME."
}

// Schema implements AgentTool.
func (t *BlackboardTool) Schema() json.RawMessage {
	return json.RawMessage(`{
  "type": "object",
  "properties": {
    "action":  {"type": "string", "enum": ["read", "post", "done"], "description": "read | post | done"},
    "path":    {"type": "string", "description": "For read: a path under the blackboard root to fetch (e.g. messages/round-1-a.md). Omit for the global snapshot."},
    "file":    {"type": "string", "description": "For post: bare file name under messages/, must end in .md (e.g. round-1-a.md)."},
    "content": {"type": "string", "description": "For post: the message body (max 32 KiB)."},
    "summary": {"type": "string", "description": "For done: final delivery summary written into DONE."}
  },
  "required": ["action"],
  "additionalProperties": false
}`)
}

// ExecutionMode implements AgentTool. It mutates shared files → sequential.
func (t *BlackboardTool) ExecutionMode() agentcore.ToolExecutionMode {
	return agentcore.ToolExecutionSequential
}

type blackboardArgs struct {
	Action  string `json:"action"`
	Path    string `json:"path"`
	File    string `json:"file"`
	Content string `json:"content"`
	Summary string `json:"summary"`
}

// Execute implements AgentTool.
func (t *BlackboardTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
	a, bad := decodeArgs[blackboardArgs](args, "blackboard")
	if bad != nil {
		return *bad, nil
	}
	if t.Root == "" {
		return errorResult("blackboard: no blackboard root configured"), nil
	}
	switch a.Action {
	case "read":
		return t.read(a)
	case "post":
		return t.post(a)
	case "done":
		return t.done(a)
	default:
		return errorResult(fmt.Sprintf("blackboard: unknown action %q (want read|post|done)", a.Action)), nil
	}
}

// read returns either one file's contents (when a.Path is set) or a global
// snapshot of the blackboard: task.md, message list, both workspace listings,
// and the DONE state.
func (t *BlackboardTool) read(a blackboardArgs) (agentcore.AgentToolResult, error) {
	if strings.TrimSpace(a.Path) != "" {
		return t.readFile(a.Path)
	}

	var b strings.Builder
	if data, err := os.ReadFile(filepath.Join(t.Root, "task.md")); err == nil {
		b.WriteString("# task.md\n")
		b.WriteString(truncateToBudget(string(data), maxBlackboardReadBytes))
		b.WriteString("\n")
	} else {
		b.WriteString("# task.md\n<missing>\n")
	}

	b.WriteString("\n# messages/ (" + strings.Join(readDirNames(t.Root, "messages"), ", ") + ")\n")
	for _, name := range readDirListing(t.Root, "messages") {
		b.WriteString("  - " + name + "\n")
	}
	b.WriteString("\n# workspace/ (your workspace)\n")
	for _, name := range readDirListing(t.Root, "workspace") {
		b.WriteString("  - " + name + "\n")
	}

	done := ""
	if data, err := os.ReadFile(filepath.Join(t.Root, "DONE")); err == nil {
		done = truncateToBudget(string(data), 4096)
	}
	b.WriteString("\n# DONE\n")
	if done == "" {
		b.WriteString("<not created yet — cooperation is still in progress>\n")
	} else {
		b.WriteString("EXISTS:\n" + done + "\n")
	}

	if env := blackboardEnvSummary(); env != "" {
		b.WriteString("\n# environment\n" + env)
	}

	return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(b.String())}}, nil
}

// readFile returns the contents of one file under the blackboard root, capped at
// maxBlackboardReadBytes. Paths are validated (no traversal) and must point
// inside the root.
func (t *BlackboardTool) readFile(p string) (agentcore.AgentToolResult, error) {
	full, err := t.safePath(p)
	if err != nil {
		return errorResult("blackboard read: " + err.Error()), nil
	}
	info, err := os.Stat(full)
	if err != nil {
		return errorResult(fmt.Sprintf("blackboard read: %s: %v", p, err)), nil
	}
	if info.IsDir() {
		return errorResult(fmt.Sprintf("blackboard read: %s is a directory; only files can be read", p)), nil
	}
	data, err := os.ReadFile(full)
	if err != nil {
		return errorResult(fmt.Sprintf("blackboard read: %s: %v", p, err)), nil
	}
	return agentcore.AgentToolResult{
		Content: agentcore.ContentList{agentcore.NewTextContent("# " + p + "\n" + truncateToBudget(string(data), maxBlackboardReadBytes))},
	}, nil
}

// post atomically appends a message to $BB/messages/<file>. The file name must
// be a bare *.md name (no separators) so a message can never escape the
// messages directory. Appending is a single O_APPEND write → atomic under
// concurrent agents.
func (t *BlackboardTool) post(a blackboardArgs) (agentcore.AgentToolResult, error) {
	name := strings.TrimSpace(a.File)
	if !validMessageName(name) {
		return errorResult("blackboard post: file must be a bare name ending in .md (e.g. \"round-1-a.md\"), no path separators, no \"..\""), nil
	}
	content := strings.TrimSpace(a.Content)
	if content == "" {
		return errorResult("blackboard post: content must not be empty"), nil
	}
	if len(content) > maxBlackboardMessageBytes {
		return errorResult(fmt.Sprintf("blackboard post: content too large (%d bytes, max %d)", len(content), maxBlackboardMessageBytes)), nil
	}

	dir := filepath.Join(t.Root, "messages")
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return errorResult("blackboard post: " + err.Error()), nil
	}
	// O_APPEND + a single Write is atomic on POSIX: concurrent posts never
	// interleave bytes. The newline separates this message from the previous one.
	f, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
	if err != nil {
		return errorResult("blackboard post: " + err.Error()), nil
	}
	_, werr := f.WriteString(content)
	cerr := f.Close()
	if werr != nil {
		return errorResult("blackboard post: write: " + werr.Error()), nil
	}
	if cerr != nil {
		return errorResult("blackboard post: close: " + cerr.Error()), nil
	}
	return agentcore.AgentToolResult{
		Content: agentcore.ContentList{agentcore.NewTextContent(fmt.Sprintf("Message appended to messages/%s", name))},
	}, nil
}

// done atomically creates the DONE marker with a delivery summary. O_CREATE|O_EXCL
// guarantees exactly one agent can create it; a second attempt reports the
// existing marker rather than overwriting it.
func (t *BlackboardTool) done(a blackboardArgs) (agentcore.AgentToolResult, error) {
	summary := strings.TrimSpace(a.Summary)
	if summary == "" {
		return errorResult("blackboard done: summary must not be empty (include the final delivery summary)"), nil
	}
	header := "Blackboard cooperation DONE\n"
	header += "created: " + time.Now().UTC().Format(time.RFC3339) + "\n\n"
	content := header + summary

	target := filepath.Join(t.Root, "DONE")
	f, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
	if err != nil {
		if os.IsExist(err) {
			existing, _ := os.ReadFile(target)
			return errorResult("blackboard done: DONE already exists — cooperation already finished:\n" + truncateToBudget(string(existing), 4096)), nil
		}
		return errorResult("blackboard done: " + err.Error()), nil
	}
	if _, werr := f.WriteString(content); werr != nil {
		f.Close()
		return errorResult("blackboard done: write: " + werr.Error()), nil
	}
	if cerr := f.Close(); cerr != nil {
		return errorResult("blackboard done: close: " + cerr.Error()), nil
	}
	return agentcore.AgentToolResult{
		Content:   agentcore.ContentList{agentcore.NewTextContent("DONE marker created. Cooperation finished.")},
		Terminate: terminatePtr(),
	}, nil
}

// safePath resolves p against the blackboard root and rejects anything that
// escapes it (.., absolute paths, symlinks are not followed beyond validation of
// the lexical path).
func (t *BlackboardTool) safePath(p string) (string, error) {
	if strings.TrimSpace(p) == "" {
		return "", fmt.Errorf("empty path")
	}
	clean := filepath.Clean(p)
	if filepath.IsAbs(clean) {
		return "", fmt.Errorf("path %q must be relative to the blackboard root", p)
	}
	rootClean := filepath.Clean(t.Root)
	full := filepath.Join(rootClean, clean)
	if full != rootClean && !strings.HasPrefix(full, rootClean+string(filepath.Separator)) {
		return "", fmt.Errorf("path %q escapes the blackboard root", p)
	}
	return full, nil
}

// validMessageName checks a post file name: a bare *.md name with no directory
// components and no "." or ".." tricks.
func validMessageName(name string) bool {
	if name == "" || !strings.HasSuffix(name, ".md") {
		return false
	}
	if strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
		return false
	}
	base := strings.TrimSuffix(name, ".md")
	if base == "" || strings.HasPrefix(base, ".") || strings.Contains(base, "..") {
		return false
	}
	return true
}

// readDirNames lists direct child names of a directory under the root (missing
// or unreadable → empty slice).
func readDirNames(root, sub string) []string {
	entries, err := os.ReadDir(filepath.Join(root, sub))
	if err != nil {
		return nil
	}
	names := make([]string, 0, len(entries))
	for _, e := range entries {
		names = append(names, e.Name())
	}
	sort.Strings(names)
	return names
}

// readDirListing lists direct children of a directory under the root with size
// and modification time, sorted by name (missing or unreadable → empty slice).
func readDirListing(root, sub string) []string {
	entries, err := os.ReadDir(filepath.Join(root, sub))
	if err != nil {
		return nil
	}
	out := make([]string, 0, len(entries))
	for _, e := range entries {
		var size string
		if info, err := e.Info(); err == nil && !info.IsDir() {
			size = fmt.Sprintf(" (%d bytes, %s)", info.Size(), info.ModTime().UTC().Format("15:04:05"))
		} else if err == nil {
			size = " (dir)"
		}
		out = append(out, e.Name()+size)
	}
	sort.Strings(out)
	return out
}

// blackboardEnvSummary renders the BB/ROUND/NAME environment values so the model
// can address messages and understand the round. Returns "" when none are set.
func blackboardEnvSummary() string {
	var b strings.Builder
	for _, k := range []string{"BB", "ROUND", "NAME"} {
		if v := strings.TrimSpace(os.Getenv(k)); v != "" {
			fmt.Fprintf(&b, "  %s=%s\n", k, v)
		}
	}
	return strings.TrimRight(b.String(), "\n")
}
