// Package webfetch is the one door Maven uses to read something off the
// network, and it is a narrow one.
//
// "Never phones home" stopped being a hard constraint on 2026-07-31, but what
// replaced it is not "she may fetch anything": local sources come first (Kiwix
// on the box), external fetching is off unless configured, and only the
// utterance ever leaves — never his notes, facts or history. That policy is
// enforced by the callers. What THIS package enforces is the part that must be
// code rather than a paragraph in a plan, because it protects the homelab from
// its own assistant:
//
//   - http/https only — no file://, no ftp://, no gopher;
//   - no private address, ever: loopback, RFC1918 (which is what makes the
//     10.42.0.0/24 wireguard tunnel and the 192.168.1.0/24 LAN unreachable),
//     link-local incl. the 169.254.169.254 cloud metadata address, CGNAT,
//     unique-local v6. Checked in the dialer's Control hook, so it holds for
//     every address the resolver returns AND for every hop of a redirect
//     chain — a DNS name that resolves to 127.0.0.1 is refused at connect
//     time, which a pre-flight lookup could not promise (rebinding);
//   - an allowlist, when one is configured, and a denylist that always wins;
//   - a response size cap, a total timeout, a redirect cap;
//   - one request per host per interval, so a poll loop with a bug is slow
//     rather than an outbound flood.
//
// Everything above is on by default with sane numbers: a zero Config is a
// usable, conservative fetcher. There is no cache and no retry — a feed poll
// or a page read that fails is simply not answered this round.
package webfetch

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/url"
	"strings"
	"sync"
	"syscall"
	"time"
)

// Defaults. Small on purpose: this reads feeds and article pages, not ISOs.
const (
	DefaultTimeout      = 20 * time.Second
	DefaultMaxBytes     = 2 << 20 // 2 MiB
	DefaultMaxRedirects = 3
	DefaultHostInterval = time.Second
	DefaultUserAgent    = "Maven/1.0 (self-hosted personal assistant)"
)

// Errors callers distinguish. Everything else is wrapped transport error.
var (
	ErrScheme    = errors.New("webfetch: only http and https are allowed")
	ErrBlocked   = errors.New("webfetch: host is not allowed")
	ErrPrivate   = errors.New("webfetch: refusing to connect to a private address")
	ErrTooLarge  = errors.New("webfetch: response exceeds the size cap")
	ErrRedirects = errors.New("webfetch: too many redirects")
	ErrStatus    = errors.New("webfetch: non-2xx status")
)

// Config are the limits. Every zero value means "the default above", so
// Config{} is safe; the only field that changes behaviour by being empty is
// AllowHosts (empty ⇒ any public host that is not denied).
type Config struct {
	// AllowHosts — when non-empty, the ONLY hosts that may be fetched. An
	// entry matches the host itself and its subdomains ("example.com" allows
	// "news.example.com"). This is the knob to reach for when a capability
	// should read two feeds and nothing else.
	AllowHosts []string
	// DenyHosts — same matching, checked first and always winning.
	DenyHosts []string

	Timeout      time.Duration // whole request, including redirects and body read
	MaxBytes     int64         // response body cap
	MaxRedirects int           // 0 ⇒ default; negative ⇒ no redirects followed
	HostInterval time.Duration // minimum spacing between requests to one host
	UserAgent    string

	// AllowPrivate disables the private-address guard. It exists for tests
	// (httptest listens on 127.0.0.1) and for an explicitly configured
	// on-box mirror. Nothing in deploy/mavend.json sets it, and it should
	// stay that way: with it on, any URL Maven is handed becomes an SSRF
	// probe of the LAN and the wireguard range.
	AllowPrivate bool
}

// Response is a fetched body, already bounded by MaxBytes.
type Response struct {
	URL         string // final URL after redirects
	Status      int
	ContentType string
	Body        []byte
	// Header — the response headers, one value each (the first). Populated for
	// every request; MCP needs Mcp-Session-Id, nothing else reads it.
	Header map[string]string
}

// Fetcher performs guarded GETs. Safe for concurrent use; the per-host rate
// limiter is shared, which is the point of sharing one Fetcher.
type Fetcher struct {
	cfg  Config
	http *http.Client

	mu   sync.Mutex
	last map[string]time.Time // host → when we last dialed it
}

// New builds a fetcher from cfg, filling in defaults.
func New(cfg Config) *Fetcher {
	if cfg.Timeout <= 0 {
		cfg.Timeout = DefaultTimeout
	}
	if cfg.MaxBytes <= 0 {
		cfg.MaxBytes = DefaultMaxBytes
	}
	if cfg.MaxRedirects == 0 {
		cfg.MaxRedirects = DefaultMaxRedirects
	}
	if cfg.HostInterval <= 0 {
		cfg.HostInterval = DefaultHostInterval
	}
	if cfg.UserAgent == "" {
		cfg.UserAgent = DefaultUserAgent
	}
	f := &Fetcher{cfg: cfg, last: map[string]time.Time{}}

	dialer := &net.Dialer{Timeout: 10 * time.Second}
	if !cfg.AllowPrivate {
		// The guard lives here rather than in a pre-flight net.LookupHost so
		// that it sees the address actually being connected to: every A/AAAA
		// the resolver handed back, on every redirect hop, with no window in
		// which the name could be re-pointed at the LAN.
		dialer.Control = func(_, address string, _ syscall.RawConn) error {
			host, _, err := net.SplitHostPort(address)
			if err != nil {
				return err
			}
			ip := net.ParseIP(host)
			if ip == nil || IsPrivateIP(ip) {
				return fmt.Errorf("%w: %s", ErrPrivate, host)
			}
			return nil
		}
	}
	f.http = &http.Client{
		Timeout:   cfg.Timeout,
		Transport: &http.Transport{DialContext: dialer.DialContext},
		CheckRedirect: func(req *http.Request, via []*http.Request) error {
			if len(via) > f.cfg.MaxRedirects {
				return ErrRedirects
			}
			// A redirect is a fresh URL and gets the full check: an allowed
			// host must not be able to bounce us onto a denied one.
			return f.checkURL(req.URL)
		},
	}
	return f
}

// Get fetches rawURL. The body is capped: a larger response is an error, not a
// truncation, because half an XML document is worse than none.
func (f *Fetcher) Get(ctx context.Context, rawURL string) (*Response, error) {
	return f.do(ctx, http.MethodGet, rawURL, nil, nil)
}

// Post sends body to rawURL and returns the reply, under exactly the same
// guards as Get: scheme rule, host lists, the dialer's private-address check on
// every hop, the size cap and the per-host rate limit.
//
// It exists for JSON-RPC over HTTP (internal/mcp), which cannot be expressed as
// a GET. That an outbound request now carries a body does not widen the
// address policy one bit — a POST to the LAN is refused for the same reason a
// GET is, unless AllowPrivate was set for that specific fetcher.
//
// hdr is merged over the defaults; a caller may not override User-Agent or
// Accept-Encoding, because identity encoding and an honest UA are part of the
// contract with whatever is on the other end.
func (f *Fetcher) Post(ctx context.Context, rawURL, contentType string, body []byte, hdr map[string]string) (*Response, error) {
	if contentType == "" {
		contentType = "application/json"
	}
	if hdr == nil {
		hdr = map[string]string{}
	}
	merged := make(map[string]string, len(hdr)+1)
	for k, v := range hdr {
		merged[k] = v
	}
	merged["Content-Type"] = contentType
	return f.do(ctx, http.MethodPost, rawURL, body, merged)
}

func (f *Fetcher) do(ctx context.Context, method, rawURL string, body []byte, hdr map[string]string) (*Response, error) {
	u, err := url.Parse(strings.TrimSpace(rawURL))
	if err != nil {
		return nil, fmt.Errorf("webfetch: bad url %q: %w", rawURL, err)
	}
	if err := f.checkURL(u); err != nil {
		return nil, err
	}
	if err := f.waitTurn(ctx, u.Hostname()); err != nil {
		return nil, err
	}

	var rdr io.Reader
	if body != nil {
		rdr = bytes.NewReader(body)
	}
	req, err := http.NewRequestWithContext(ctx, method, u.String(), rdr)
	if err != nil {
		return nil, err
	}
	for k, v := range hdr {
		req.Header.Set(k, v)
	}
	req.Header.Set("User-Agent", f.cfg.UserAgent)
	req.Header.Set("Accept-Encoding", "identity")

	resp, err := f.http.Do(req)
	if err != nil {
		// http.Client wraps our sentinels in *url.Error; unwrap so callers can
		// still tell "blocked" from "the network is down".
		for _, sentinel := range []error{ErrPrivate, ErrBlocked, ErrRedirects, ErrScheme} {
			if errors.Is(err, sentinel) {
				return nil, err
			}
		}
		return nil, err
	}
	defer resp.Body.Close()

	respBody, err := io.ReadAll(io.LimitReader(resp.Body, f.cfg.MaxBytes+1))
	if err != nil {
		return nil, err
	}
	if int64(len(respBody)) > f.cfg.MaxBytes {
		return nil, fmt.Errorf("%w (%d bytes)", ErrTooLarge, f.cfg.MaxBytes)
	}
	if resp.StatusCode < 200 || resp.StatusCode > 299 {
		return nil, fmt.Errorf("%w: %d", ErrStatus, resp.StatusCode)
	}
	out := &Response{
		URL:         resp.Request.URL.String(),
		Status:      resp.StatusCode,
		ContentType: resp.Header.Get("Content-Type"),
		Body:        respBody,
		Header:      map[string]string{},
	}
	for k := range resp.Header {
		out.Header[k] = resp.Header.Get(k)
	}
	return out, nil
}

// checkURL applies the scheme rule and the host lists. The address rule is the
// dialer's job (see New).
func (f *Fetcher) checkURL(u *url.URL) error {
	switch u.Scheme {
	case "http", "https":
	default:
		return fmt.Errorf("%w: %q", ErrScheme, u.Scheme)
	}
	host := strings.ToLower(u.Hostname())
	if host == "" {
		return fmt.Errorf("%w: no host", ErrBlocked)
	}
	if HostMatches(host, f.cfg.DenyHosts) {
		return fmt.Errorf("%w: %s is denied", ErrBlocked, host)
	}
	if len(f.cfg.AllowHosts) > 0 && !HostMatches(host, f.cfg.AllowHosts) {
		return fmt.Errorf("%w: %s is not on the allowlist", ErrBlocked, host)
	}
	// A literal private address is refused here as well as in the dialer, so
	// the error is the specific one even when no connection is attempted.
	if !f.cfg.AllowPrivate {
		if ip := net.ParseIP(host); ip != nil && IsPrivateIP(ip) {
			return fmt.Errorf("%w: %s", ErrPrivate, host)
		}
	}
	return nil
}

// waitTurn blocks until this host's rate-limit interval has elapsed. It holds
// no lock while sleeping, so two hosts never wait on each other.
func (f *Fetcher) waitTurn(ctx context.Context, host string) error {
	for {
		f.mu.Lock()
		now := time.Now()
		earliest := f.last[host].Add(f.cfg.HostInterval)
		if !now.Before(earliest) {
			f.last[host] = now
			f.mu.Unlock()
			return nil
		}
		f.mu.Unlock()
		wait := time.NewTimer(earliest.Sub(now))
		select {
		case <-ctx.Done():
			wait.Stop()
			return ctx.Err()
		case <-wait.C:
		}
	}
}

// HostMatches reports whether host equals one of pats or is a subdomain of one.
// Exported because the crawler applies the same rule to links it decides not to
// follow, before it ever builds a request.
func HostMatches(host string, pats []string) bool {
	host = strings.ToLower(strings.TrimSuffix(host, "."))
	for _, p := range pats {
		p = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(p, "*.")))
		if p == "" {
			continue
		}
		if host == p || strings.HasSuffix(host, "."+p) {
			return true
		}
	}
	return false
}

// cgnat is 100.64.0.0/10 — carrier NAT, not covered by net.IP's helpers and not
// somewhere a personal assistant has business connecting.
var cgnat = &net.IPNet{IP: net.IPv4(100, 64, 0, 0).To4(), Mask: net.CIDRMask(10, 32)}

// IsPrivateIP reports whether ip is somewhere Maven must never reach out to:
// the box itself, the LAN, the wireguard range (10.42.0.0/24 ⊂ 10/8), the cloud
// metadata address (169.254.169.254 ⊂ link-local), or anything unroutable.
func IsPrivateIP(ip net.IP) bool {
	if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() ||
		ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
		ip.IsInterfaceLocalMulticast() || ip.IsMulticast() {
		return true
	}
	if v4 := ip.To4(); v4 != nil && cgnat.Contains(v4) {
		return true
	}
	// IPv4-mapped/compatible forms of the above are handled by To4() inside the
	// stdlib helpers; what is left is v6 unique-local (fc00::/7).
	if len(ip) == net.IPv6len && ip.To4() == nil && ip[0]&0xfe == 0xfc {
		return true
	}
	return false
}
