package runtime

import (
	"bytes"
	"context"
	"fmt"
	"html"
	"io"
	"mime"
	"net"
	"net/http"
	"net/netip"
	"net/url"
	"regexp"
	"strings"
	"time"
)

const (
	maxWebSearchResultURLs = 5
	maxWebURLBytes         = 2048
	maxWebReadBodyBytes    = 2 << 20
	webReadTimeout         = 10 * time.Second
	webReadUserAgent       = "MeshX-KnowledgeQA/1.0"
)

func (runtime *Runtime) explorationWebSearch(
	ctx context.Context,
	refs *explorationReferences,
	arguments knowledgeWebSearchArguments,
	usage *ToolUsage,
) (explorationWebSearchResult, error) {
	usage.WebSearchCalls++
	result := explorationWebSearchResult{OK: true, Results: make([]explorationWebSearchResultItem, 0, maxWebSearchResultURLs)}
	if runtime == nil || runtime.WebSearchProvider == nil {
		return explorationWebSearchResult{
			OK: false, Results: []explorationWebSearchResultItem{},
			Hint: "web search provider is not configured",
		}, nil
	}
	searchResults, err := runtime.WebSearchProvider.Search(ctx, arguments.Query, maxWebSearchResultURLs)
	if err != nil {
		return explorationWebSearchResult{
			OK: false, Results: []explorationWebSearchResultItem{},
			Hint: "web search provider failed; retry later or answer without web evidence",
		}, nil
	}
	seen := make(map[string]struct{}, len(searchResults))
	for _, item := range searchResults {
		if len(result.Results) >= maxWebSearchResultURLs {
			break
		}
		itemURL, err := normalizePublicWebURL(item.URL)
		if err != nil {
			continue
		}
		if _, duplicate := seen[itemURL]; duplicate {
			continue
		}
		seen[itemURL] = struct{}{}
		title := strings.TrimSpace(item.Title)
		webRef := refs.webRef(explorationWebReference{URL: itemURL, Title: title})
		result.Results = append(result.Results, explorationWebSearchResultItem{
			WebRef: webRef, URL: itemURL, Title: title,
		})
	}
	usage.WebSources = len(refs.webs)
	if len(result.Results) == 0 {
		result.Hint = "no web search results"
	}
	return result, nil
}

func (runtime *Runtime) explorationWebRead(
	ctx context.Context,
	refs *explorationReferences,
	arguments knowledgeWebReadArguments,
	evidenceNumber int,
	usage *ToolUsage,
) (explorationWebReadResult, *Evidence, error) {
	reference, ok := refs.resolveWeb(arguments.WebRef)
	if !ok {
		return explorationWebReadResult{}, nil, handleRejected()
	}
	usage.WebReadCalls++
	page, err := runtime.fetchWebPage(ctx, reference.URL)
	if err != nil {
		return explorationWebReadResult{
			OK: false, Truncated: page.Truncated,
		}, nil, nil
	}
	if strings.TrimSpace(page.Text) == "" {
		return explorationWebReadResult{OK: true, Truncated: page.Truncated}, nil, nil
	}
	title := firstNonEmptyWebString(page.Title, reference.Title)
	snippet := firstNonEmptyWebString(page.Snippet, reference.Snippet)
	source := firstNonEmptyWebString(page.Source, reference.Source, webURLSource(page.URL), webURLSource(reference.URL))
	fetchedAt := runtime.now().Format(time.RFC3339Nano)
	lineEnd := max(1, strings.Count(page.Text, "\n")+1)
	citation := CitationMetadata{
		CitationKind: CitationKindWeb,
		URL:          page.URL,
		Title:        title,
		Snippet:      snippet,
		FetchedAt:    fetchedAt,
		LineStart:    1,
		LineEnd:      lineEnd,
	}
	evidence := &Evidence{
		ID:       fmt.Sprintf("E%d", evidenceNumber),
		Content:  page.Text,
		Citation: citation,
	}
	refs.webs[arguments.WebRef] = explorationWebReference{
		URL: page.URL, Title: title, Snippet: snippet, Source: source,
	}
	usage.WebSources = len(refs.webs)
	return explorationWebReadResult{OK: true, Evidence: &explorationWebReadEvidence{
		ID: evidence.ID, WebRef: arguments.WebRef, Title: title, URL: page.URL,
		Source: source, FetchedAt: fetchedAt, Content: evidence.Content,
	}, Truncated: page.Truncated}, evidence, nil
}

type fetchedWebPage struct {
	URL       string
	Title     string
	Snippet   string
	Source    string
	Text      string
	Truncated bool
}

func (runtime *Runtime) fetchWebPage(ctx context.Context, rawURL string) (fetchedWebPage, error) {
	normalizedURL, err := normalizePublicWebURL(rawURL)
	if err != nil {
		return fetchedWebPage{}, err
	}
	client := runtime.webHTTPClient()
	requestCtx, cancel := context.WithTimeout(ctx, webReadTimeout)
	defer cancel()
	request, err := http.NewRequestWithContext(requestCtx, http.MethodGet, normalizedURL, nil)
	if err != nil {
		return fetchedWebPage{}, err
	}
	request.Header.Set("User-Agent", webReadUserAgent)
	response, err := client.Do(request)
	if err != nil {
		return fetchedWebPage{}, err
	}
	defer response.Body.Close()
	if response.StatusCode < 200 || response.StatusCode >= 300 {
		return fetchedWebPage{}, fmt.Errorf("web page returned status %d", response.StatusCode)
	}
	body, truncated, err := readBoundedWebBody(response.Body)
	if err != nil {
		return fetchedWebPage{URL: normalizedURL, Truncated: truncated}, err
	}
	mediaType := webContentType(response.Header.Get("Content-Type"))
	if mediaType == "" {
		mediaType = webContentType(http.DetectContentType(body))
	}
	switch mediaType {
	case "text/html":
		page, err := extractHTMLWebPage(body)
		if err != nil {
			return fetchedWebPage{URL: response.Request.URL.String(), Truncated: truncated}, err
		}
		page.URL = response.Request.URL.String()
		page.Truncated = truncated || page.Truncated
		return page, nil
	case "text/plain":
		text := boundedReadResultString(string(body))
		return fetchedWebPage{
			URL: response.Request.URL.String(), Title: webURLSource(response.Request.URL.String()),
			Text: text, Truncated: truncated || len(text) < len(string(body)),
		}, nil
	default:
		return fetchedWebPage{URL: response.Request.URL.String(), Truncated: truncated},
			fmt.Errorf("unsupported web content type %q", mediaType)
	}
}

func (runtime *Runtime) webHTTPClient() *http.Client {
	if runtime != nil && runtime.WebReadClient != nil {
		return runtime.WebReadClient
	}
	return defaultWebReadClient
}

var defaultWebReadClient = NewWebReadClient()

// NewWebReadClient returns the default HTTP client used for Knowledge Q&A web reads.
func NewWebReadClient() *http.Client {
	return &http.Client{
		Timeout: webReadTimeout,
		Transport: &http.Transport{
			Proxy:                 http.ProxyFromEnvironment,
			DialContext:           publicWebDialContext,
			DisableKeepAlives:     true,
			TLSHandshakeTimeout:   webReadTimeout,
			ResponseHeaderTimeout: webReadTimeout,
			ExpectContinueTimeout: time.Second,
		},
		CheckRedirect: func(request *http.Request, via []*http.Request) error {
			if len(via) >= 5 {
				return http.ErrUseLastResponse
			}
			_, err := normalizePublicWebURL(request.URL.String())
			return err
		},
	}
}

func readBoundedWebBody(reader io.Reader) ([]byte, bool, error) {
	var buffer bytes.Buffer
	_, err := buffer.ReadFrom(io.LimitReader(reader, maxWebReadBodyBytes+1))
	if err != nil {
		return nil, false, err
	}
	if buffer.Len() > maxWebReadBodyBytes {
		return buffer.Bytes()[:maxWebReadBodyBytes], true, nil
	}
	return buffer.Bytes(), false, nil
}

var (
	webTitleRE          = regexp.MustCompile(`(?is)<title\b[^>]*>(.*?)</title>`)
	webBodyRE           = regexp.MustCompile(`(?is)<body\b[^>]*>(.*?)</body>`)
	webDiscardElementRE = regexp.MustCompile(`(?is)<(?:script|style|noscript)\b[^>]*>.*?</(?:script|style|noscript)>`)
	webBlockElementRE   = regexp.MustCompile(`(?is)</?(?:article|section|main|header|footer|nav|aside|div|p|br|hr|li|ul|ol|h[1-6]|blockquote|pre|table|thead|tbody|tfoot|tr|td|th)\b[^>]*>`)
	webTagRE            = regexp.MustCompile(`(?s)<[^>]*>`)
)

func extractHTMLWebPage(body []byte) (fetchedWebPage, error) {
	markup := string(body)
	title := normalizeExtractedWebText(firstHTMLSubmatch(webTitleRE, markup))
	text := boundedReadResultString(extractHTMLBodyText(markup))
	return fetchedWebPage{Title: title, Text: text}, nil
}

func extractHTMLBodyText(markup string) string {
	markup = webDiscardElementRE.ReplaceAllString(markup, " ")
	if body := firstHTMLSubmatch(webBodyRE, markup); body != "" {
		markup = body
	}
	markup = webBlockElementRE.ReplaceAllString(markup, "\n")
	markup = webTagRE.ReplaceAllString(markup, " ")
	return normalizeExtractedWebText(markup)
}

func firstHTMLSubmatch(expression *regexp.Regexp, value string) string {
	matches := expression.FindStringSubmatch(value)
	if len(matches) < 2 {
		return ""
	}
	return matches[1]
}

func normalizeExtractedWebText(value string) string {
	value = html.UnescapeString(value)
	lines := strings.Split(value, "\n")
	result := make([]string, 0, len(lines))
	for _, line := range lines {
		line = strings.Join(strings.Fields(line), " ")
		if line != "" {
			result = append(result, line)
		}
	}
	return strings.TrimSpace(strings.Join(result, "\n"))
}

func boundedReadResultString(value string) string {
	if len(value) <= maxReadResultBytes {
		return value
	}
	return value[:maxReadResultBytes]
}

func webContentType(value string) string {
	mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(value))
	if err != nil {
		return ""
	}
	return strings.ToLower(mediaType)
}

func webURLSource(rawURL string) string {
	parsed, err := url.Parse(strings.TrimSpace(rawURL))
	if err != nil {
		return ""
	}
	return strings.TrimPrefix(strings.ToLower(parsed.Hostname()), "www.")
}

func normalizePublicWebURL(raw string) (string, error) {
	raw = strings.TrimSpace(raw)
	if raw == "" || len(raw) > maxWebURLBytes {
		return "", fmt.Errorf("web URL is invalid")
	}
	parsed, err := url.Parse(raw)
	if err != nil || !parsed.IsAbs() || parsed.Host == "" {
		return "", fmt.Errorf("web URL is invalid")
	}
	if parsed.Scheme != "http" && parsed.Scheme != "https" {
		return "", fmt.Errorf("web URL scheme is unsupported")
	}
	if !publicWebHostAllowed(parsed.Hostname()) {
		return "", fmt.Errorf("web URL host is not allowed")
	}
	parsed.Fragment = ""
	return parsed.String(), nil
}

func publicWebHostAllowed(host string) bool {
	host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
	if host == "" || host == "localhost" {
		return false
	}
	if strings.HasSuffix(host, ".localhost") {
		return false
	}
	if ip, err := netip.ParseAddr(host); err == nil {
		return publicWebIPAllowed(ip)
	}
	return true
}

func publicWebDialContext(ctx context.Context, network, address string) (net.Conn, error) {
	dialer := net.Dialer{Timeout: webReadTimeout, KeepAlive: 30 * time.Second}
	conn, err := dialer.DialContext(ctx, network, address)
	if err != nil {
		return nil, err
	}
	remote, ok := conn.RemoteAddr().(*net.TCPAddr)
	if !ok {
		_ = conn.Close()
		return nil, fmt.Errorf("web URL host is not allowed")
	}
	addr, ok := netip.AddrFromSlice(remote.IP)
	if !ok || !publicWebIPAllowed(addr) {
		_ = conn.Close()
		return nil, fmt.Errorf("web URL host is not allowed")
	}
	return conn, nil
}

func publicWebIPAllowed(ip netip.Addr) bool {
	if ip.Is4In6() {
		ip = ip.Unmap()
	}
	return ip.IsGlobalUnicast() && !ip.IsPrivate() && !ip.IsLoopback() &&
		!ip.IsLinkLocalUnicast() && !ip.IsLinkLocalMulticast() && !ip.IsMulticast() &&
		!ip.IsUnspecified()
}

func firstNonEmptyWebString(values ...string) string {
	for _, value := range values {
		if trimmed := strings.TrimSpace(value); trimmed != "" {
			return trimmed
		}
	}
	return ""
}
