package services

import (
	"context"
	"encoding/base64"
	"errors"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"net/url"
	"os"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"github.com/chromedp/cdproto/browser"
	"github.com/chromedp/cdproto/cdp"
	"github.com/chromedp/cdproto/fetch"
	"github.com/chromedp/cdproto/network"
	"github.com/chromedp/chromedp"
)

type crawlRenderedPage struct{ HTML, URL string }
type crawlPageRenderer func(context.Context, string) (crawlRenderedPage, error)

const (
	crawlBrowserTimeout = 45 * time.Second
	crawlResourceLimit  = 180
	crawlByteLimit      = 40 << 20
)

// Each render has a fresh temporary profile, removed by chromedp on exit. One
// browser at a time keeps concurrent imports from exhausting a small server.
var crawlBrowserSlot = make(chan struct{}, 1)

func renderCrawlPage(ctx context.Context, target string) (crawlRenderedPage, error) {
	if strings.EqualFold(os.Getenv("CRAWL_BROWSER_ENABLED"), "false") {
		return crawlRenderedPage{}, errors.New("Halaman membutuhkan pemuatan lanjutan, tetapi pembaca website dinamis dinonaktifkan. Hubungi admin atau gunakan halaman lain.")
	}
	client := newCrawlHTTPClient()
	defer client.CloseIdleConnections()
	// Let Chrome see redirects, so relative asset URLs use the final address.
	// Every hop is intercepted again and uses the same public-IP-only transport.
	client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
	return renderCrawlPageUsing(ctx, target, client)
}

func renderCrawlPageUsing(parent context.Context, target string, client *http.Client) (crawlRenderedPage, error) {
	var result crawlRenderedPage
	base, err := url.Parse(target)
	if err != nil || base.Host == "" || base.User != nil || (base.Scheme != "http" && base.Scheme != "https") {
		return result, errors.New("Alamat website tidak valid.")
	}
	select {
	case crawlBrowserSlot <- struct{}{}:
		defer func() { <-crawlBrowserSlot }()
	case <-parent.Done():
		return result, errors.New("Waktu menunggu pembaca website habis. Coba ambil ulang website.")
	}
	ctx, cancelDeadline := context.WithTimeout(parent, crawlBrowserTimeout)
	defer cancelDeadline()

	// Chrome itself cannot access the network. Only intercepted GET/HEAD
	// requests fulfilled through the Go SSRF-safe transport receive data.
	// The deny proxy also covers workers, popups, WebSockets, and other traffic
	// not intercepted by this tab's CDP session. No proxy bypass for localhost.
	listener, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		return result, errors.New("Pembaca website belum dapat dimulai. Coba lagi.")
	}
	proxy := &http.Server{ReadHeaderTimeout: 3 * time.Second, IdleTimeout: 3 * time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusForbidden)
	})}
	go func() { _ = proxy.Serve(listener) }()
	defer proxy.Close()
	opts := append([]chromedp.ExecAllocatorOption{}, chromedp.DefaultExecAllocatorOptions[:]...)
	opts = append(opts,
		chromedp.ProxyServer("http://"+listener.Addr().String()),
		chromedp.Flag("proxy-bypass-list", "<-loopback>"),
		chromedp.Flag("host-resolver-rules", "MAP * ~NOTFOUND, EXCLUDE 127.0.0.1"),
		chromedp.Flag("disable-quic", true),
		chromedp.Flag("force-webrtc-ip-handling-policy", "disable_non_proxied_udp"),
		chromedp.Flag("no-sandbox", false),
	)
	if path := strings.TrimSpace(os.Getenv("CRAWL_CHROME_PATH")); path != "" {
		opts = append(opts, chromedp.ExecPath(path))
	}
	alloc, cancelAlloc := chromedp.NewExecAllocator(ctx, opts...)
	defer cancelAlloc()
	tab, cancelTab := chromedp.NewContext(alloc)
	defer cancelTab()
	requestsCtx, cancelRequests := context.WithCancel(tab)
	queue := make(chan *fetch.EventRequestPaused, crawlResourceLimit)
	var pending, requests, transferred atomic.Int64
	var limitReached atomic.Bool
	var documentError atomic.Value
	documentError.Store("")
	var mainFrame atomic.Value
	mainFrame.Store("")
	var workers sync.WaitGroup
	for i := 0; i < 6; i++ {
		workers.Add(1)
		go func() {
			defer workers.Done()
			for {
				select {
				case <-requestsCtx.Done():
					return
				case event := <-queue:
					func() {
						defer pending.Add(-1)
						execCtx := cdp.WithExecutor(requestsCtx, chromedp.FromContext(tab).Target)
						response, body, err := fetchCrawlBrowserResource(requestsCtx, client, base, event)
						if err == nil && transferred.Add(int64(len(body))) > crawlByteLimit {
							limitReached.Store(true)
							cancelDeadline()
							return
						}
						isMainDocument := event.ResourceType == network.ResourceTypeDocument && string(event.FrameID) == mainFrame.Load().(string)
						if err != nil {
							if isMainDocument {
								documentError.Store("Halaman belum dapat dibuka oleh pembaca website. Pastikan alamatnya publik dan tidak memerlukan login.")
							}
							_ = fetch.FailRequest(event.RequestID, network.ErrorReasonBlockedByClient).Do(execCtx)
							return
						}
						if isMainDocument && response.StatusCode >= 400 {
							documentError.Store(fmt.Sprintf("Website mengembalikan status HTTP %d. Coba lagi setelah halaman dapat dibuka.", response.StatusCode))
						}
						_ = fetch.FulfillRequest(event.RequestID, int64(response.StatusCode)).
							WithResponseHeaders(crawlBrowserResponseHeaders(response.Header)).
							WithBody(base64.StdEncoding.EncodeToString(body)).Do(execCtx)
					}()
				}
			}
		}()
	}
	defer func() { cancelRequests(); workers.Wait() }()
	chromedp.ListenTarget(requestsCtx, func(event any) {
		if paused, ok := event.(*fetch.EventRequestPaused); ok {
			if paused.ResourceType == network.ResourceTypeDocument && mainFrame.Load().(string) == "" {
				mainFrame.Store(string(paused.FrameID))
			}
			if requests.Add(1) > crawlResourceLimit {
				limitReached.Store(true)
				cancelDeadline()
				return
			}
			pending.Add(1)
			queue <- paused
		}
	})
	if err := chromedp.Run(tab, fetch.Enable(), chromedp.ActionFunc(func(ctx context.Context) error {
		return browser.SetDownloadBehavior(browser.SetDownloadBehaviorBehaviorDeny).Do(ctx)
	})); err != nil {
		log.Printf("[crawl] browser startup failed: %v", err)
		return result, errors.New("Halaman membutuhkan pemuatan lanjutan. Pembaca website belum tersedia di server; minta admin memeriksa instalasi Chrome atau Chromium.")
	}
	if err := chromedp.Run(tab, chromedp.Navigate(target)); err != nil {
		return result, crawlBrowserFailure(&limitReached, documentError.Load().(string))
	}
	ticker := time.NewTicker(300 * time.Millisecond)
	defer ticker.Stop()
	lastText, stableSince := "", time.Now()
	for {
		var snapshot struct {
			Text string `json:"text"`
			URL  string `json:"url"`
		}
		err := chromedp.Run(tab, chromedp.Evaluate(`(() => {
			const root = document.querySelector('main, [role=main], article') || document.body;
			return {text: (root?.innerText || '').slice(0, 3000000), url: location.href};
		})()`, &snapshot))
		if err != nil {
			return result, crawlBrowserFailure(&limitReached, documentError.Load().(string))
		}
		if snapshot.Text != lastText || pending.Load() != 0 {
			lastText, stableSince = snapshot.Text, time.Now()
		}
		if CrawlContentIssue(snapshot.Text) == "" && time.Since(stableSince) >= 1200*time.Millisecond {
			if message := documentError.Load().(string); message != "" {
				return result, errors.New(message)
			}
			result.URL = snapshot.URL
			if err := chromedp.Run(tab, chromedp.Evaluate(`document.documentElement.outerHTML.length <= 3145728 ? document.documentElement.outerHTML : ''`, &result.HTML)); err != nil {
				return result, crawlBrowserFailure(&limitReached, "")
			}
			if result.HTML == "" || len(result.HTML) > maxPageBytes {
				return crawlRenderedPage{}, errors.New("Isi halaman melebihi batas 3 MB. Gunakan tautan halaman yang lebih spesifik.")
			}
			log.Printf("[crawl] browser read %s%s: %d characters, %d requests", base.Hostname(), base.Path, len([]rune(snapshot.Text)), requests.Load())
			return result, nil
		}
		select {
		case <-ctx.Done():
			return result, crawlBrowserFailure(&limitReached, documentError.Load().(string))
		case <-ticker.C:
		}
	}
}

func crawlBrowserFailure(limit *atomic.Bool, message string) error {
	if limit.Load() {
		return errors.New("Halaman terlalu berat untuk diproses. Gunakan tautan produk atau informasi yang lebih spesifik.")
	}
	if message != "" {
		return errors.New(message)
	}
	return errors.New("Isi website belum selesai dimuat dalam batas waktu. Coba lagi atau gunakan tautan halaman yang lebih spesifik.")
}

func fetchCrawlBrowserResource(ctx context.Context, client *http.Client, base *url.URL, event *fetch.EventRequestPaused) (*http.Response, []byte, error) {
	u, err := url.Parse(event.Request.URL)
	if err != nil || u.User != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
		return nil, nil, errors.New("unsupported URL")
	}
	if event.Request.Method != http.MethodGet && event.Request.Method != http.MethodHead && event.Request.Method != http.MethodOptions {
		return nil, nil, errors.New("read-only requests only")
	}
	switch event.ResourceType {
	case network.ResourceTypeImage, network.ResourceTypeMedia, network.ResourceTypeFont, network.ResourceTypeWebSocket:
		return nil, nil, errors.New("resource not needed for knowledge")
	case network.ResourceTypeDocument:
		if canonicalHost(u.Hostname()) != canonicalHost(base.Hostname()) {
			return nil, nil, errors.New("document left source domain")
		}
	}
	req, err := http.NewRequestWithContext(ctx, event.Request.Method, u.String(), nil)
	if err != nil {
		return nil, nil, err
	}
	// Never forward cookies, authorization, or user credentials from the browser.
	req.Header.Set("User-Agent", crawlUA)
	for key, value := range event.Request.Headers {
		switch strings.ToLower(key) {
		case "accept", "accept-language", "origin", "referer", "content-type", "access-control-request-method", "access-control-request-headers":
			if text, ok := value.(string); ok {
				req.Header.Set(key, text)
			}
		}
	}
	response, err := client.Do(req)
	if err != nil {
		return nil, nil, err
	}
	defer response.Body.Close()
	body, err := io.ReadAll(io.LimitReader(response.Body, maxPageBytes+1))
	if err != nil || len(body) > maxPageBytes {
		return nil, nil, errors.New("resource exceeds size limit or could not be read")
	}
	return response, body, nil
}

func crawlBrowserResponseHeaders(header http.Header) []*fetch.HeaderEntry {
	var result []*fetch.HeaderEntry
	for key, values := range header {
		switch strings.ToLower(key) {
		case "content-length", "transfer-encoding", "content-encoding", "set-cookie", "connection":
			continue
		}
		for _, value := range values {
			result = append(result, &fetch.HeaderEntry{Name: key, Value: value})
		}
	}
	return result
}
