package pinterest

import (
	"context"
	"fmt"
	"io"
	"math/rand"
	"net/http"
	"strings"
	"time"

	fhttp "github.com/bogdanfinn/fhttp"
	tls_client "github.com/bogdanfinn/tls-client"

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

const baseURL = "https://in.pinterest.com"

type userAgent struct {
	ua       string
	secChUa  string
	platform string
}

var userAgents = []userAgent{
	{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", `"Chromium";v="138", "Not/A)Brand";v="24", "Google Chrome";v="138"`, `"Windows"`},
	{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Edg/137.0.0.0", `"Chromium";v="137", "Not/A)Brand";v="24", "Microsoft Edge";v="137"`, `"Windows"`},
	{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", `"Chromium";v="138", "Not/A)Brand";v="24", "Google Chrome";v="138"`, `"macOS"`},
	{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15", `"Not/A)Brand";v="24", "Safari";v="18"`, `"macOS"`},
	{"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0", ``, `"Windows"`},
	{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0", ``, `"macOS"`},
	{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36 OPR/120.0.0.0", `"Chromium";v="136", "Not/A)Brand";v="24", "Opera";v="120"`, `"Windows"`},
	{"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", `"Chromium";v="138", "Not/A)Brand";v="24", "Google Chrome";v="138"`, `"Linux"`},
	{"Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0", ``, `"Linux"`},
	{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.0.0", `"Chromium";v="135", "Not/A)Brand";v="24", "Microsoft Edge";v="135"`, `"Windows"`},
	{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36 Edg/136.0.0.0", `"Chromium";v="136", "Not/A)Brand";v="24", "Microsoft Edge";v="136"`, `"macOS"`},
	{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Vivaldi/7.4", `"Chromium";v="138", "Not/A)Brand";v="24", "Vivaldi";v="7"`, `"Windows"`},
}

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}
}

func (c *Client) headers(sourceURL string) map[string]string {
	ua := userAgents[rand.Intn(len(userAgents))]
	h := map[string]string{
		"Host":                       "in.pinterest.com",
		"Sec-Ch-Ua-Platform":         ua.platform,
		"Sec-Ch-Ua-Mobile":           "?0",
		"X-Requested-With":           "XMLHttpRequest",
		"Accept":                     "application/json, text/javascript, */*, q=0.01",
		"X-Pinterest-Source-Url":     sourceURL,
		"X-Pinterest-Appstate":       "active",
		"Accept-Language":            "en-US,en;q=0.9",
		"Screen-Dpr":                 "1",
		"X-Pinterest-Pws-Handler":    "www/search/[scope].js",
		"User-Agent":                 ua.ua,
		"Sec-Ch-Ua-Platform-Version": `""`,
		"Sec-Fetch-Site":             "same-origin",
		"Sec-Fetch-Mode":             "cors",
		"Sec-Fetch-Dest":             "empty",
		"Referer":                    baseURL + "/",
		"Priority":                   "u=1, i",
	}
	if ua.secChUa != "" {
		h["Sec-Ch-Ua"] = ua.secChUa
	}
	return h
}

// get performs one HTTP GET with retries. Every failed attempt (transport
// error, non-200, or a 200 with a session-rejected error body) drops the TLS
// session bank for that proxy and reports the failure, so the next attempt
// takes the next pool entry and exits via a fresh IP from proxies.txt or the
// datacenter rotation. Each freshly built session gets at most one warmup
// request (tracked by the TLS pool), never one per keyword.
func (c *Client) get(ctx context.Context, url, sourceURL string) ([]byte, int, error) {
	var lastErr error
	var lastStatus int
	var lastRejected []byte
	for attempt := 0; attempt < c.cfg.MaxRetries; attempt++ {
		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, 0, err
		}
		httpClient, needsWarm := c.sessions.Take(proxyURL)
		if httpClient == nil {
			return nil, 0, fmt.Errorf("session creation failed")
		}
		if needsWarm && c.cfg.Go.WarmupRequests {
			c.warmupSession(ctx, httpClient, limiterKey, sourceURL)
		}

		req, err := fhttp.NewRequest(http.MethodGet, url, nil)
		if err != nil {
			return nil, 0, err
		}
		for k, v := range c.headers(sourceURL) {
			req.Header.Set(k, v)
		}
		req = req.WithContext(ctx)

		c.metrics.Request("pinterest")
		resp, err := httpClient.Do(req)
		if err != nil {
			lastErr = err
			lastStatus = 0
			c.metrics.Fail("pinterest")
			c.recycle(proxyURL, entry, 30*time.Second)
			backoffSleep(attempt)
			continue
		}
		body, _ := io.ReadAll(resp.Body)
		resp.Body.Close()
		lastStatus = resp.StatusCode

		if resp.StatusCode == 429 || resp.StatusCode == 403 {
			c.metrics.RateLimited("pinterest")
			lastErr = fmt.Errorf("status %d", resp.StatusCode)
			c.recycle(proxyURL, entry, 60*time.Second)
			backoffSleep(attempt)
			continue
		}
		if resp.StatusCode != 200 {
			c.metrics.Fail("pinterest")
			lastErr = fmt.Errorf("status %d", resp.StatusCode)
			c.recycle(proxyURL, entry, 30*time.Second)
			backoffSleep(attempt)
			continue
		}
		if isRejectedBody(body) {
			lastErr = fmt.Errorf("session rejected (error body)")
			lastRejected = body
			c.recycle(proxyURL, entry, 30*time.Second)
			backoffSleep(attempt)
			continue
		}
		c.metrics.OK("pinterest")
		if entry != nil {
			entry.ReportSuccess()
		}
		return body, resp.StatusCode, nil
	}
	if lastRejected != nil && lastStatus == 200 {
		return lastRejected, lastStatus, nil
	}
	return nil, lastStatus, fmt.Errorf("pinterest: retries exhausted: %w", lastErr)
}

// isRejectedBody reports a 200 response whose tiny error payload means the
// session never reached the search (missing cookies, burned IP). The caller
// recycles the session so the retry exits via a new IP.
func isRejectedBody(body []byte) bool {
	return len(body) < 400 && strings.Contains(string(body), `"error"`)
}

// recycle drops the TLS session bank for the proxy (fresh cookie jar and TLS
// fingerprint on the next attempt) and reports the failure so the proxy pool
// can cool the entry down. Proxy rotation itself happens on the next
// pool.Next() call.
func (c *Client) recycle(proxyURL string, entry *proxypool.Entry, cooldown time.Duration) {
	c.sessions.Reset(proxyURL)
	if entry != nil {
		entry.ReportFailure(cooldown)
	}
}

// warmupSession sends one bootstrap request on a freshly built session so
// Pinterest sets its cookies before the first search. It is throttled like a
// normal request so the global rate limit still bounds total traffic.
func (c *Client) warmupSession(ctx context.Context, httpClient tls_client.HttpClient, limiterKey, sourceURL string) {
	if err := c.limiter.Wait(ctx, limiterKey); err != nil {
		return
	}
	req, err := fhttp.NewRequest(http.MethodGet, baseURL+sourceURL, nil)
	if err != nil {
		return
	}
	for k, v := range c.headers(sourceURL) {
		req.Header.Set(k, v)
	}
	req = req.WithContext(ctx)
	resp, err := httpClient.Do(req)
	if err != nil {
		return
	}
	_, _ = io.Copy(io.Discard, resp.Body)
	resp.Body.Close()
}

func backoffSleep(attempt int) {
	wait := time.Duration(1<<attempt)*time.Second + time.Duration(rand.Intn(3000))*time.Millisecond
	time.Sleep(wait)
}

// Search performs the merged search in a single request: images and
// descriptions are extracted from one response. When descriptions are wanted
// the request is sized up front (snippet_max_page_size) instead of escalating
// page_size over several requests, which used to cost up to 5 requests per
// keyword against the rate limit.
func (c *Client) Search(ctx context.Context, query string, wantImages int, wantDesc int) (Descriptions, error) {
	return c.searchOnce(ctx, query, c.pageSizeFor(wantImages, wantDesc))
}

// pageSizeFor computes the page size of the single request per keyword.
func (c *Client) pageSizeFor(wantImages, wantDesc int) int {
	pageSize := wantImages
	if wantDesc > pageSize {
		pageSize = wantDesc
	}
	if pageSize < 1 {
		pageSize = c.cfg.ImageResult
	}
	if wantDesc > 0 && pageSize < c.cfg.SnippetMaxPageSize {
		pageSize = c.cfg.SnippetMaxPageSize
	}
	return pageSize
}

func (c *Client) searchOnce(ctx context.Context, query string, pageSize int) (Descriptions, error) {
	url, sourceURL := BuildSearchURL(baseURL, query, pageSize)

	body, status, err := c.get(ctx, url, sourceURL)
	if err != nil {
		if status != 0 {
			return Descriptions{}, fmt.Errorf("search failed for '%s': status %d: %w", query, status, err)
		}
		return Descriptions{}, fmt.Errorf("search failed for '%s': %w", query, err)
	}

	resp, err := ParseSearchResponse(body)
	if err != nil {
		return Descriptions{}, fmt.Errorf("parse failed for '%s': %w", query, err)
	}
	return Extract(resp, query), nil
}
