// Package websearch reads a self-hosted SearXNG instance.
//
// Why this exists at all: "never phones home" stopped being a hard constraint
// on 2026-07-31. A 1.7B does not know enough to answer a world question, and
// reading beats recalling at that size. SearXNG is the reading surface for
// anything the offline ZIMs do not hold, and it is off unless configured.
//
// What is NOT here, on purpose:
//
//   - No query rewriting. SearXNG ranks with real engines, so the Russian
//     question goes out as he asked it. That is the whole reason it sits ahead
//     of Kiwix, whose keyword ranker needs kiwix.Rewriter to see anything.
//   - No page fetching. A snippet per result is the evidence; following a link
//     is crawl.Crawler's job and carries robots and allowlist rules with it.
//   - No cache and no retries. Boring on purpose, same posture as kiwix.Client.
//
// Only the query string leaves this process. This package cannot read the
// store, so his notes, facts, persona block and history cannot travel with a
// search even by accident. The personal boundary in the query chain is what
// keeps a question ABOUT him from becoming a query at all.
package websearch

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

// Result is one search hit, already reduced to what a phraser can read.
type Result struct {
	Title   string
	URL     string
	Content string // the engine's snippet, plain text
	Engine  string // which upstream engine produced it, e.g. "duckduckgo"
}

// Response is one search. Answers comes from SearXNG's answerer plugins and
// from instant answers upstream; it is a direct reply to the question and is
// worth more than any snippet, so it is kept separate rather than mixed in.
type Response struct {
	Answers []string
	Results []Result
}

// Empty reports whether the search found nothing usable. The caller passes the
// turn on when it does — an empty search is not a failure worth announcing.
func (r Response) Empty() bool { return len(r.Answers) == 0 && len(r.Results) == 0 }

// DefaultTimeout — the whole request. SearXNG fans out to upstream engines and
// waits on the slowest, so this is longer than a LAN call but short enough that
// a dead engine does not hold a voice turn open.
const DefaultTimeout = 8 * time.Second

// dialTimeout — how long a connection to the instance may take before the turn
// gives up on it and falls through to the ZIM.
//
// It is separate from DefaultTimeout because the two failures are different
// (V-508). A reachable instance that is merely slow deserves the full budget:
// it is fanning out to real engines. A host that never answers a SYN deserves
// almost none, and the difference was measured. With the container stopped, DNS
// failed and the ZIM answered inside the same second. With the host blackholed,
// the search sat for the whole 8 seconds and the turn took 15.4 seconds instead
// of 3.5, which is a wait he sits through with nothing being said.
//
// The instance is on the LAN or the same host, so a connection it will ever
// accept is accepted in milliseconds.
const dialTimeout = 1500 * time.Millisecond

// maxBodyBytes caps the JSON read. A 20-result reply is tens of kilobytes; this
// is slack for a wide one and a hard stop against a misconfigured endpoint.
const maxBodyBytes = 4 << 20

// Client is a SearXNG HTTP client.
type Client struct {
	base     string
	language string
	engines  string
	http     *http.Client
}

// Options are the per-instance knobs, all optional.
type Options struct {
	// Language — SearXNG's `language` parameter, e.g. "ru" or "auto". Empty ⇒
	// the instance default.
	Language string
	// Engines — comma-separated engine names to restrict the search to. Empty ⇒
	// whatever the instance has enabled.
	Engines string
	// Timeout — per-request budget. 0 ⇒ DefaultTimeout.
	Timeout time.Duration
}

// New makes a client for a SearXNG base URL like http://searxng:9563.
//
// The instance must have the JSON format enabled (`search.formats: [html,
// json]` in its settings.yml); a stock install answers 403 to format=json and
// every search will fail with that status.
func New(baseURL string, opt Options) *Client {
	t := opt.Timeout
	if t <= 0 {
		t = DefaultTimeout
	}
	return &Client{
		base:     strings.TrimRight(baseURL, "/"),
		language: strings.TrimSpace(opt.Language),
		engines:  strings.TrimSpace(opt.Engines),
		http: &http.Client{
			Timeout: t,
			// Cloned from the default so the rest of the transport (proxy,
			// keep-alives, HTTP/2) keeps stock behaviour and only the dial
			// budget changes.
			Transport: dialCappedTransport(),
		},
	}
}

// Search runs one query and returns up to limit results plus any instant
// answers. The query goes out verbatim.
func (c *Client) Search(ctx context.Context, query string, limit int) (Response, error) {
	query = strings.TrimSpace(query)
	if query == "" {
		return Response{}, fmt.Errorf("websearch: empty query")
	}
	q := url.Values{}
	q.Set("q", query)
	q.Set("format", "json")
	if c.language != "" {
		q.Set("language", c.language)
	}
	if c.engines != "" {
		q.Set("engines", c.engines)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+"/search?"+q.Encode(), nil)
	if err != nil {
		return Response{}, err
	}
	resp, err := c.http.Do(req)
	if err != nil {
		return Response{}, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return Response{}, fmt.Errorf("websearch: http %d (json format enabled in searxng?)", resp.StatusCode)
	}
	body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
	if err != nil {
		return Response{}, err
	}
	return ParseResponse(body, limit)
}

// dialCappedTransport is http.DefaultTransport with dialTimeout on the connect
// phase. A read that has already connected still gets the full request budget.
func dialCappedTransport() *http.Transport {
	tr := http.DefaultTransport.(*http.Transport).Clone()
	tr.DialContext = (&net.Dialer{Timeout: dialTimeout, KeepAlive: 30 * time.Second}).DialContext
	return tr
}

// wire mirrors just the fields of the SearXNG JSON reply we read.
type wire struct {
	Answers []json.RawMessage `json:"answers"`
	Results []struct {
		Title   string `json:"title"`
		URL     string `json:"url"`
		Content string `json:"content"`
		Engine  string `json:"engine"`
	} `json:"results"`
}

// ParseResponse turns a SearXNG JSON reply into a Response, keeping at most
// limit results. Exported so the parser is testable from a captured reply with
// no instance running.
func ParseResponse(body []byte, limit int) (Response, error) {
	var doc wire
	if err := json.Unmarshal(body, &doc); err != nil {
		return Response{}, fmt.Errorf("websearch: bad json: %w", err)
	}
	if limit <= 0 {
		limit = 5
	}
	out := Response{}
	for _, raw := range doc.Answers {
		if s := answerText(raw); s != "" {
			out.Answers = append(out.Answers, s)
		}
	}
	for _, r := range doc.Results {
		title := clean(r.Title)
		content := clean(r.Content)
		if title == "" && content == "" {
			// A hit with no text is a link with nothing to read. It cannot be
			// evidence, and counting it toward the limit would push a usable
			// snippet out of the reply.
			continue
		}
		out.Results = append(out.Results, Result{
			Title:   title,
			URL:     strings.TrimSpace(r.URL),
			Content: content,
			Engine:  strings.TrimSpace(r.Engine),
		})
		if len(out.Results) == limit {
			break
		}
	}
	return out, nil
}

// answerText reads one entry of `answers`. SearXNG changed its shape: older
// versions emit a bare string, newer ones an object with an `answer` field.
// Both are in the wild depending on when the instance was pulled, so both are
// read rather than pinning a version we do not control.
func answerText(raw json.RawMessage) string {
	var s string
	if err := json.Unmarshal(raw, &s); err == nil {
		return clean(s)
	}
	var obj struct {
		Answer string `json:"answer"`
	}
	if err := json.Unmarshal(raw, &obj); err == nil {
		return clean(obj.Answer)
	}
	return ""
}

// Snippets renders the response as evidence lines for a phraser: instant
// answers first, then "Title — snippet" per result.
//
// Answers lead because they are a reply to the question, where a result is a
// page that might contain one. The URL is deliberately left out: it is not
// evidence, and piper reads one out character by character.
func (r Response) Snippets() []string {
	out := make([]string, 0, len(r.Answers)+len(r.Results))
	out = append(out, r.Answers...)
	for _, res := range r.Results {
		switch {
		case res.Content == "":
			out = append(out, res.Title)
		case res.Title == "":
			out = append(out, res.Content)
		default:
			out = append(out, res.Title+" — "+res.Content)
		}
	}
	return out
}

// clean collapses whitespace. Snippets arrive with newlines and runs of spaces
// from the upstream page, and piper reads a reply built out of them badly.
func clean(s string) string { return strings.Join(strings.Fields(s), " ") }
