package suggest

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/url"
	"regexp"
	"strings"
	"sync"
	"time"

	fhttp "github.com/bogdanfinn/fhttp"

	"pinscrape-allgo/internal/config"
	"pinscrape-allgo/internal/metrics"
	"pinscrape-allgo/internal/proxypool"
	"pinscrape-allgo/internal/ratelimit"
	"pinscrape-allgo/internal/tlspool"
)

var tagRe = regexp.MustCompile(`<[^>]+>`)

type Client struct {
	cfg      *config.Config
	pool     *proxypool.Pool
	sessions *tlspool.Pool
	limiter  *ratelimit.Limiter
	metrics  *metrics.Metrics
}

func New(cfg *config.Config, pool *proxypool.Pool, sessions *tlspool.Pool, limiter *ratelimit.Limiter, m *metrics.Metrics) *Client {
	return &Client{cfg: cfg, pool: pool, sessions: sessions, limiter: limiter, metrics: m}
}

// FetchRelatedKeywords resolves related keywords for a seed. DuckDuckGo
// autocomplete is the primary source; Google Suggest (the original source) is
// the fallback when DuckDuckGo errors out or returns nothing. Google-specific
// settings (suggest_hl / suggest_gl) only apply to the fallback.
func (c *Client) FetchRelatedKeywords(ctx context.Context, keyword string, maxResults int) ([]string, error) {
	results, ddgErr := c.fetchDuckDuckGo(ctx, keyword, maxResults)
	if ddgErr == nil && len(results) > 0 {
		return results, nil
	}
	if ddgErr != nil {
		log.Printf("  suggest ddg warn %s: %v -> fallback google", keyword, ddgErr)
	}
	results, gErr := c.fetchGoogle(ctx, keyword, maxResults)
	if gErr != nil && ddgErr != nil {
		return nil, fmt.Errorf("related keywords for '%s': duckduckgo: %v; google: %w", keyword, ddgErr, gErr)
	}
	return results, gErr
}

// fetchDuckDuckGo queries the DuckDuckGo autocomplete endpoint (type=list).
func (c *Client) fetchDuckDuckGo(ctx context.Context, keyword string, maxResults int) ([]string, error) {
	u := fmt.Sprintf("https://duckduckgo.com/ac/?q=%s&type=list", url.QueryEscape(keyword))
	body, err := c.get(ctx, u, "duckduckgo", keyword)
	if err != nil {
		return nil, err
	}
	return parseDuckDuckGo(body, maxResults), nil
}

// fetchGoogle queries Google Suggest, the legacy source kept as fallback.
func (c *Client) fetchGoogle(ctx context.Context, keyword string, maxResults int) ([]string, error) {
	u := fmt.Sprintf(
		"https://www.google.com/complete/search?cp=1&client=gws-wiz&xssi=t&gs_pcrt=undefined&hl=%s&gl=%s&authuser=0&dpr=1&q=%s",
		c.cfg.SuggestHl, c.cfg.SuggestGl, url.QueryEscape(keyword),
	)
	body, err := c.get(ctx, u, "google", keyword)
	if err != nil {
		return nil, err
	}
	return parseGoogleSuggestions(body, maxResults), nil
}

// get fetches a suggest endpoint through the shared proxy/session/rate-limit
// machinery. The host label feeds the metrics counters ("duckduckgo" or
// "google"); a failed request recycles the session bank so the next attempt
// rotates to a fresh IP (parity with the Pinterest client).
func (c *Client) get(ctx context.Context, rawURL, host, keyword string) ([]byte, error) {
	entry := c.pool.Next()
	proxyURL := ""
	limiterKey := "direct"
	if entry != nil {
		proxyURL = entry.URL
		limiterKey = entry.URL
	}
	if err := c.limiter.Wait(ctx, limiterKey); err != nil {
		return nil, err
	}
	httpClient := c.sessions.Get(proxyURL)
	if httpClient == nil {
		return nil, fmt.Errorf("suggest: session creation failed")
	}

	req, err := fhttp.NewRequest("GET", rawURL, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36")
	req.Header.Set("Accept", "application/json, text/javascript, */*, q=0.01")
	if host == "duckduckgo" {
		req.Header.Set("Referer", "https://duckduckgo.com/")
	}
	req = req.WithContext(ctx)

	c.metrics.Request(host)
	resp, err := httpClient.Do(req)
	if err != nil {
		c.metrics.Fail(host)
		c.recycle(proxyURL, entry, 30*time.Second)
		return nil, fmt.Errorf("suggest %s request failed for '%s': %w", host, keyword, err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	if resp.StatusCode != 200 {
		c.metrics.Fail(host)
		cooldown := 30 * time.Second
		if resp.StatusCode == 429 || resp.StatusCode == 403 {
			c.metrics.RateLimited(host)
			cooldown = 60 * time.Second
		}
		c.recycle(proxyURL, entry, cooldown)
		return nil, fmt.Errorf("suggest %s status %d for '%s'", host, resp.StatusCode, keyword)
	}
	c.metrics.OK(host)
	return body, nil
}

// parseDuckDuckGo extracts suggestions from a DuckDuckGo autocomplete payload.
// Two shapes are accepted: ["query", ["sug", ...]] from type=list and
// [{"phrase":"sug"}, ...] from the default endpoint. An unexpected shape
// yields nil so the caller falls back to Google.
func parseDuckDuckGo(body []byte, maxResults int) []string {
	var top []json.RawMessage
	if err := json.Unmarshal(body, &top); err != nil || len(top) == 0 {
		return nil
	}

	// type=list: ["query", ["sug1", "sug2", ...]]
	if len(top) == 2 {
		var seed string
		var sugs []string
		if json.Unmarshal(top[0], &seed) == nil && json.Unmarshal(top[1], &sugs) == nil {
			return cleanSuggestions(sugs, maxResults)
		}
	}

	// default shape: [{"phrase":"sug1"}, {"phrase":"sug2"}, ...]
	var phrases []string
	for _, item := range top {
		var obj struct {
			Phrase string `json:"phrase"`
		}
		if err := json.Unmarshal(item, &obj); err != nil {
			return nil
		}
		phrases = append(phrases, obj.Phrase)
	}
	return cleanSuggestions(phrases, maxResults)
}

// parseGoogleSuggestions extracts suggestions from a Google Suggest payload,
// with or without the XSSI )]}' prefix. An unexpected shape yields nil.
func parseGoogleSuggestions(body []byte, maxResults int) []string {
	raw := string(body)
	if strings.HasPrefix(raw, ")]}'") {
		raw = strings.TrimPrefix(raw, ")]}'")
		raw = strings.TrimPrefix(raw, "\n")
	}

	var top []json.RawMessage
	if err := json.Unmarshal([]byte(raw), &top); err != nil || len(top) == 0 {
		return nil
	}
	var items []json.RawMessage
	if err := json.Unmarshal(top[0], &items); err != nil {
		return nil
	}
	var texts []string
	for _, item := range items {
		var pair []interface{}
		if err := json.Unmarshal(item, &pair); err != nil || len(pair) == 0 {
			continue
		}
		if text, ok := pair[0].(string); ok {
			texts = append(texts, text)
		}
	}
	return cleanSuggestions(texts, maxResults)
}

// cleanSuggestions strips markup, trims, drops empties and caps the list.
func cleanSuggestions(in []string, maxResults int) []string {
	var out []string
	for _, s := range in {
		s = tagRe.ReplaceAllString(s, "")
		s = strings.TrimSpace(s)
		if s == "" {
			continue
		}
		out = append(out, s)
		if maxResults > 0 && len(out) >= maxResults {
			break
		}
	}
	return out
}

// recycle drops the TLS session bank for the proxy and cools the entry so the
// next attempt rotates to a fresh IP (parity with the Pinterest client).
func (c *Client) recycle(proxyURL string, entry *proxypool.Entry, cooldown time.Duration) {
	c.sessions.Reset(proxyURL)
	if entry != nil {
		entry.ReportFailure(cooldown)
	}
}

// FetchParallel runs the suggest chain (DuckDuckGo first, Google fallback) for
// several keywords concurrently and returns the results keyed by keyword.
func (c *Client) FetchParallel(ctx context.Context, keywords []string, maxResults int) map[string][]string {
	out := make(map[string][]string, len(keywords))
	if len(keywords) == 0 {
		return out
	}
	type res struct {
		kw  string
		kws []string
	}
	ch := make(chan res, len(keywords))
	sem := make(chan struct{}, 16)
	var wg sync.WaitGroup

	for _, kw := range keywords {
		wg.Add(1)
		go func(k string) {
			defer wg.Done()
			sem <- struct{}{}
			defer func() { <-sem }()
			kws, _ := c.FetchRelatedKeywords(ctx, k, maxResults)
			ch <- res{kw: k, kws: kws}
		}(kw)
	}
	wg.Wait()
	close(ch)

	for r := range ch {
		if len(r.kws) > 0 {
			out[r.kw] = r.kws
		}
	}
	return out
}
