// This file implements the websearch tool: run a web search and return the top
// results (title, URL, snippet) as Markdown. pi has no such tool; this mirrors
// Claude Code's WebSearch. It is provider-agnostic and auto-detects a backend by
// available credentials so it works out of the box:
//
//   - Tavily      when TAVILY_API_KEY is set (LLM-optimized results).
//   - Brave       when BRAVE_API_KEY is set (independent index).
//   - DuckDuckGo   as a keyless fallback (HTML endpoint, no API key needed).
//
// The first backend whose credential is present wins; DuckDuckGo is always the
// last-resort fallback. Optional allowed/blocked domain filters are applied
// uniformly to every backend by post-filtering the result hosts, so behavior is
// consistent regardless of which backend served the query.
package agenttool

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"os"
	"strings"
	"time"

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

// webSearchTimeout bounds a single search request. webSearchDefaultCount is used
// when the caller omits count; webSearchMaxCount caps it so a run cannot pull an
// unbounded result set into the model's context.
const (
	webSearchTimeout      = 15 * time.Second
	webSearchDefaultCount = 5
	webSearchMaxCount     = 10
)

// WebSearchTool runs a web search via the first available backend. The zero
// value is usable: Client defaults to an http.Client with webSearchTimeout and
// getenv defaults to os.Getenv (both injected in tests).
type WebSearchTool struct {
	// Client performs backend HTTP requests. When nil, a default client bounded by
	// webSearchTimeout is built. Injected for tests to serve canned responses.
	Client *http.Client
	// getenv reads credentials for backend selection. When nil, os.Getenv is used.
	// Injected for tests so backend selection is deterministic without touching the
	// process environment.
	getenv func(string) string
}

// webSearchArgs is the decoded argument shape for WebSearchTool.
type webSearchArgs struct {
	// Query is the search query (required).
	Query string `json:"query"`
	// Count is the desired number of results (optional; clamped to webSearchMaxCount).
	Count int `json:"count,omitempty"`
	// AllowedDomains, when non-empty, keeps only results whose host matches one of
	// these domains (suffix match). BlockedDomains drops results whose host matches.
	AllowedDomains []string `json:"allowed_domains,omitempty"`
	BlockedDomains []string `json:"blocked_domains,omitempty"`
}

// searchResult is one normalized hit shared across backends.
type searchResult struct {
	Title   string
	URL     string
	Snippet string
}

// Name implements AgentTool.
func (t *WebSearchTool) Name() string { return "websearch" }

// Description implements AgentTool.
func (t *WebSearchTool) Description() string {
	return "Search the web and return the top results (title, URL, snippet). " +
		"Auto-selects a backend by available credentials (Tavily, Brave, or a " +
		"keyless DuckDuckGo fallback). Use allowed_domains/blocked_domains to " +
		"restrict results by host. Follow up with the webfetch tool to read a result."
}

// Schema implements AgentTool.
func (t *WebSearchTool) Schema() json.RawMessage {
	return json.RawMessage(`{
  "type": "object",
  "properties": {
    "query":           {"type": "string", "description": "The search query."},
    "count":           {"type": "integer", "description": "Desired number of results (default 5, max 10).", "minimum": 1, "maximum": 10},
    "allowed_domains": {"type": "array", "items": {"type": "string"}, "description": "Only include results from these domains (suffix match)."},
    "blocked_domains": {"type": "array", "items": {"type": "string"}, "description": "Exclude results from these domains (suffix match)."}
  },
  "required": ["query"],
  "additionalProperties": false
}`)
}

// ExecutionMode implements AgentTool. A search has no local side effects and is
// safe to run alongside other reads → parallel.
func (t *WebSearchTool) ExecutionMode() agentcore.ToolExecutionMode {
	return agentcore.ToolExecutionParallel
}

// Execute implements AgentTool. Backend failures are encoded as error results
// (the returned Go error is always nil), matching the file tools' contract.
func (t *WebSearchTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
	a, bad := decodeArgs[webSearchArgs](args, "websearch")
	if bad != nil {
		return *bad, nil
	}
	query := strings.TrimSpace(a.Query)
	if query == "" {
		return errorResult("websearch: query is required"), nil
	}

	count := a.Count
	if count <= 0 {
		count = webSearchDefaultCount
	}
	if count > webSearchMaxCount {
		count = webSearchMaxCount
	}

	client := t.Client
	if client == nil {
		client = &http.Client{Timeout: webSearchTimeout}
	}
	getenv := t.getenv
	if getenv == nil {
		getenv = os.Getenv
	}

	backend := selectSearchBackend(getenv)
	// A domain-filtered query can discard most raw hits, so over-fetch before
	// filtering to still land near the requested count.
	fetchCount := count
	if len(a.AllowedDomains) > 0 || len(a.BlockedDomains) > 0 {
		fetchCount = min(webSearchMaxCount, count*3)
	}

	results, err := backend.search(ctx, client, query, fetchCount)
	if err != nil {
		return errorResult(fmt.Sprintf("websearch: %s backend failed: %v", backend.name(), err)), nil
	}
	results = filterByDomain(results, a.AllowedDomains, a.BlockedDomains)
	if len(results) > count {
		results = results[:count]
	}

	return agentcore.AgentToolResult{
		Content: agentcore.ContentList{agentcore.NewTextContent(renderSearchResults(query, backend.name(), results))},
		Details: map[string]any{"backend": backend.name(), "query": query, "count": len(results)},
	}, nil
}

// renderSearchResults formats the hits as a numbered Markdown list, noting which
// backend served the query so the model knows the source.
func renderSearchResults(query, backend string, results []searchResult) string {
	var b strings.Builder
	fmt.Fprintf(&b, "Search results for %q (via %s):\n", query, backend)
	if len(results) == 0 {
		b.WriteString("\n(no results)")
		return b.String()
	}
	for i, r := range results {
		fmt.Fprintf(&b, "\n%d. %s\n   %s\n", i+1, strings.TrimSpace(r.Title), strings.TrimSpace(r.URL))
		if s := strings.TrimSpace(r.Snippet); s != "" {
			fmt.Fprintf(&b, "   %s\n", s)
		}
	}
	return strings.TrimRight(b.String(), "\n")
}

// filterByDomain keeps only results whose host suffix-matches an allowed domain
// (when allowed is non-empty) and drops any whose host suffix-matches a blocked
// domain. An unparseable URL is dropped only under an allow-list.
func filterByDomain(results []searchResult, allowed, blocked []string) []searchResult {
	if len(allowed) == 0 && len(blocked) == 0 {
		return results
	}
	out := results[:0:0]
	for _, r := range results {
		host := hostOf(r.URL)
		if len(allowed) > 0 && !matchesAnyDomain(host, allowed) {
			continue
		}
		if matchesAnyDomain(host, blocked) {
			continue
		}
		out = append(out, r)
	}
	return out
}

// hostOf extracts the lowercased host from a result URL, or "" if unparseable.
func hostOf(raw string) string {
	u, err := url.Parse(raw)
	if err != nil {
		return ""
	}
	return strings.ToLower(u.Hostname())
}

// matchesAnyDomain reports whether host equals or is a subdomain of any domain.
func matchesAnyDomain(host string, domains []string) bool {
	if host == "" {
		return false
	}
	for _, d := range domains {
		d = strings.ToLower(strings.TrimSpace(d))
		if d == "" {
			continue
		}
		if host == d || strings.HasSuffix(host, "."+d) {
			return true
		}
	}
	return false
}
