package main

import (
	"bufio"
	"context"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"html"
	"io"
	"net/http"
	"net/url"
	"os"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"golang.org/x/text/encoding/charmap"
)

type result struct {
	KW          string   `json:"kw"`
	Suggestions []string `json:"suggestions,omitempty"`
	Error       string   `json:"error,omitempty"`
}

type seedResult struct {
	Seed        string
	Suggestions []string
	Requests    int64
	Errors      int64
}

type appConfig struct {
	HL          string `json:"hl"`
	GeoLock     string `json:"geo_lock"`
	GeoLockOnly bool   `json:"geo_lock_only"`
}

type keywordQueue struct {
	mu     sync.Mutex
	cond   *sync.Cond
	items  []string
	closed bool
}

func newKeywordQueue() *keywordQueue {
	q := &keywordQueue{items: make([]string, 0, 4096)}
	q.cond = sync.NewCond(&q.mu)
	return q
}

func (q *keywordQueue) Push(s string) {
	q.mu.Lock()
	if q.closed {
		q.mu.Unlock()
		return
	}
	q.items = append(q.items, s)
	q.cond.Signal()
	q.mu.Unlock()
}

func (q *keywordQueue) Pop() (string, bool) {
	q.mu.Lock()
	for len(q.items) == 0 && !q.closed {
		q.cond.Wait()
	}
	if len(q.items) == 0 {
		q.mu.Unlock()
		return "", false
	}
	s := q.items[0]
	q.items[0] = ""
	q.items = q.items[1:]
	q.mu.Unlock()
	return s, true
}

func (q *keywordQueue) Close() {
	q.mu.Lock()
	q.closed = true
	q.cond.Broadcast()
	q.mu.Unlock()
}

func (q *keywordQueue) Len() int {
	q.mu.Lock()
	n := len(q.items)
	q.mu.Unlock()
	return n
}

func main() {
	configPath := findArgValue("-config", "configs.json")
	cfg, err := loadConfig(configPath)
	if err != nil {
		fmt.Fprintln(os.Stderr, err.Error())
		os.Exit(1)
	}
	if strings.TrimSpace(cfg.HL) == "" {
		cfg.HL = "id"
	}

	flag.String("config", configPath, "Path to configs.json")
	inputPath := flag.String("input", "kw.txt", "Path to kw.txt (one keyword per line)")
	outPath := flag.String("out", "suggestion.txt", "Output .txt path for collected suggestions (one per line). Set to empty to disable")
	hl := flag.String("hl", cfg.HL, "Language code")
	gl := flag.String("gl", cfg.GeoLock, "Geo location (e.g., ID)")
	geoLockOnly := flag.Bool("geo-lock-only", cfg.GeoLockOnly, "Only allow gl to match config geo_lock")
	deep := flag.Bool("deep", true, "Deep crawl: discovered suggestions become new keywords")
	max := flag.Int("max", 0, "Max requests per seed; 0=unlimited")
	target := flag.Int("target", 1000, "Target number of unique suggestions per seed")
	includeBase := flag.Bool("include-base", false, "Include the base query (seed) without suffix")
	alphaSuffix := flag.Bool("alpha", true, "Seed with alphabet suffix queries: seed a .. seed z")
	digitSuffix := flag.Bool("digits", false, "Seed with digit suffix queries: seed 0 .. seed 9")
	concurrency := flag.Int("concurrency", 1000, "Number of concurrent requests")
	seedConcurrency := flag.Int("seed-concurrency", 50, "Number of seeds to process in parallel")
	delay := flag.Duration("delay", 250*time.Millisecond, "Delay between requests")
	progressEvery := flag.Int("progress", 25, "Print progress every N processed requests; 0=disable")
	printLines := flag.Bool("print", true, "Print each new unique result to stdout as: N - suggestion")
	seedHeader := flag.Bool("seed-header", false, "Write '# seed: <keyword>' header lines into output file")
	proxyFile := flag.String("proxy-file", "proxy.txt", "Path to proxy.txt (first non-empty line used as proxy)")
	proxyStr := flag.String("proxy", "", "HTTP proxy: host:port, host:port:user:pass, user:pass@host:port, or full URL like http://user:pass@host:port. Overrides -proxy-file")
	jsonOut := flag.Bool("json", false, "Write per-keyword JSON results to stdout")
	flag.Parse()
	if *concurrency < 1 {
		*concurrency = 1
	}
	if *seedConcurrency < 1 {
		*seedConcurrency = 1
	}
	if *geoLockOnly {
		want := strings.TrimSpace(cfg.GeoLock)
		got := strings.TrimSpace(*gl)
		if want != "" && !strings.EqualFold(want, got) {
			fmt.Fprintln(os.Stderr, "geo lock enabled: only gl="+want+" is allowed")
			os.Exit(1)
		}
		if want == "" && !strings.EqualFold(got, "ID") {
			fmt.Fprintln(os.Stderr, "geo lock enabled: only gl=ID is allowed")
			os.Exit(1)
		}
	}

	f, err := os.Open(*inputPath)
	if err != nil {
		fmt.Fprintln(os.Stderr, err.Error())
		os.Exit(1)
	}
	defer f.Close()

	transport, err := defaultTransport()
	if err != nil {
		fmt.Fprintln(os.Stderr, err.Error())
		os.Exit(1)
	}
	proxyValue := strings.TrimSpace(*proxyStr)
	proxyValues := make([]string, 0, 8)
	if proxyValue != "" {
		proxyValues = append(proxyValues, proxyValue)
	} else {
		proxyValues, err = readNonEmptyLines(*proxyFile)
		if err != nil {
			fmt.Fprintln(os.Stderr, err.Error())
			os.Exit(1)
		}
	}
	if len(proxyValues) > 0 {
		proxyURLs := make([]*url.URL, 0, len(proxyValues))
		for _, pv := range proxyValues {
			pu, err := parseHTTPProxy(pv)
			if err != nil {
				fmt.Fprintln(os.Stderr, err.Error())
				os.Exit(1)
			}
			proxyURLs = append(proxyURLs, pu)
		}
		var n uint64
		transport.Proxy = func(*http.Request) (*url.URL, error) {
			idx := int(atomic.AddUint64(&n, 1)-1) % len(proxyURLs)
			return proxyURLs[idx], nil
		}
	}
	client := &http.Client{Timeout: 15 * time.Second, Transport: transport}

	scanner := bufio.NewScanner(f)
	scanner.Buffer(make([]byte, 1024), 1024*1024)

	var encoder *json.Encoder
	if *jsonOut {
		encoder = json.NewEncoder(os.Stdout)
		encoder.SetEscapeHTML(false)
	}

	seeds := make([]string, 0, 256)
	seenSeed := map[string]struct{}{}
	for scanner.Scan() {
		for _, kw := range splitKeywords(scanner.Text()) {
			if kw == "" {
				continue
			}
			if _, ok := seenSeed[kw]; ok {
				continue
			}
			seenSeed[kw] = struct{}{}
			seeds = append(seeds, kw)
		}
	}
	if err := scanner.Err(); err != nil {
		fmt.Fprintln(os.Stderr, err.Error())
		os.Exit(1)
	}
	if len(seeds) == 0 {
		fmt.Fprintln(os.Stderr, "kw.txt has no keywords")
		os.Exit(1)
	}

	var outFile *os.File
	var outWriter *bufio.Writer
	var ioMu sync.Mutex
	if strings.TrimSpace(*outPath) != "" {
		outFile, err = os.Create(*outPath)
		if err != nil {
			fmt.Fprintln(os.Stderr, err.Error())
			os.Exit(1)
		}
		defer outFile.Close()
		outWriter = bufio.NewWriter(outFile)
		defer outWriter.Flush()
	}

	effectiveSeedConcurrency := *seedConcurrency
	if effectiveSeedConcurrency > len(seeds) {
		effectiveSeedConcurrency = len(seeds)
	}
	if effectiveSeedConcurrency < 1 {
		effectiveSeedConcurrency = 1
	}
	perSeedConcurrency := *concurrency / effectiveSeedConcurrency
	if perSeedConcurrency < 1 {
		perSeedConcurrency = 1
	}

	seedJobs := make(chan string, effectiveSeedConcurrency*2)
	seedResults := make(chan seedResult, effectiveSeedConcurrency*2)
	var seedWorkers sync.WaitGroup
	seedWorkers.Add(effectiveSeedConcurrency)
	for i := 0; i < effectiveSeedConcurrency; i++ {
		go func() {
			defer seedWorkers.Done()
			for seed := range seedJobs {
				suggestions, reqProcessed, reqErrors := processSeed(seed, client, *hl, *gl, *target, *max, perSeedConcurrency, *delay, *deep, *progressEvery, *includeBase, *alphaSuffix, *digitSuffix, encoder, &ioMu)
				seedResults <- seedResult{Seed: seed, Suggestions: suggestions, Requests: reqProcessed, Errors: reqErrors}
			}
		}()
	}
	go func() {
		for _, seed := range seeds {
			seedJobs <- seed
		}
		close(seedJobs)
		seedWorkers.Wait()
		close(seedResults)
	}()

	var totalAll int64
	for r := range seedResults {
		if outWriter != nil && *seedHeader {
			ioMu.Lock()
			_, _ = outWriter.WriteString("# seed: " + r.Seed + "\n")
			ioMu.Unlock()
		}
		if *printLines && !*jsonOut {
			ioMu.Lock()
			fmt.Fprintf(os.Stdout, "SEED: %s\n", r.Seed)
			ioMu.Unlock()
		}
		var perSeedCount int64
		for i, s := range r.Suggestions {
			if outWriter != nil {
				ioMu.Lock()
				_, _ = outWriter.WriteString(s)
				_ = outWriter.WriteByte('\n')
				ioMu.Unlock()
			}
			perSeedCount++
			if *printLines && !*jsonOut {
				ioMu.Lock()
				fmt.Fprintf(os.Stdout, "%d - %s\n", i+1, s)
				ioMu.Unlock()
			}
		}
		totalAll += perSeedCount
		if *jsonOut || !*printLines {
			fmt.Fprintf(os.Stderr, "seed=%q suggestions=%d requests=%d errors=%d\n", r.Seed, perSeedCount, r.Requests, r.Errors)
		}
		if *printLines && !*jsonOut {
			ioMu.Lock()
			fmt.Fprintf(os.Stdout, "TOTAL(%s): %d\n", r.Seed, perSeedCount)
			ioMu.Unlock()
		}
		if outWriter != nil {
			ioMu.Lock()
			_, _ = outWriter.WriteString("\n")
			ioMu.Unlock()
		}
	}

	if *printLines && !*jsonOut {
		ioMu.Lock()
		fmt.Fprintf(os.Stdout, "TOTAL_ALL: %d\n", totalAll)
		ioMu.Unlock()
	}

}

func processSeed(seed string, client *http.Client, hl, gl string, target int, maxRequests int, concurrency int, delay time.Duration, deep bool, progressEvery int, includeBase bool, alphaSuffix bool, digitSuffix bool, encoder *json.Encoder, ioMu *sync.Mutex) (suggestions []string, processed int64, errors int64) {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	queue := newKeywordQueue()
	resultCh := make(chan result, concurrency*4)

	seen := map[string]struct{}{}
	var seenMu sync.Mutex
	var pending int64
	var submitted int64
	var printed int64
	if target > 0 {
		suggestions = make([]string, 0, minInt(target, 2048))
	} else {
		suggestions = make([]string, 0, 2048)
	}

	submit := func(q string) {
		q = strings.TrimSpace(q)
		if q == "" {
			return
		}
		if maxRequests > 0 {
			for {
				curr := atomic.LoadInt64(&submitted)
				if curr >= int64(maxRequests) {
					return
				}
				if atomic.CompareAndSwapInt64(&submitted, curr, curr+1) {
					break
				}
			}
		} else {
			atomic.AddInt64(&submitted, 1)
		}
		atomic.AddInt64(&pending, 1)
		queue.Push(q)
	}

	addSuggestion := func(s string) {
		if target > 0 && atomic.LoadInt64(&printed) >= int64(target) {
			return
		}
		seenMu.Lock()
		_, ok := seen[s]
		if !ok {
			seen[s] = struct{}{}
		}
		seenMu.Unlock()
		if ok {
			return
		}
		n := atomic.AddInt64(&printed, 1)
		suggestions = append(suggestions, s)
		if deep && (target == 0 || n < int64(target)) {
			submit(s)
		}
		if target > 0 && n >= int64(target) {
			cancel()
			queue.Close()
		}
	}

	for _, q := range initialQueries(seed, includeBase, alphaSuffix, digitSuffix) {
		submit(q)
	}

	var workers sync.WaitGroup
	workers.Add(concurrency)
	for i := 0; i < concurrency; i++ {
		go func() {
			defer workers.Done()
			for {
				q, ok := queue.Pop()
				if !ok {
					return
				}
				if delay > 0 {
					time.Sleep(delay)
				}
				if ctx.Err() != nil {
					resultCh <- result{KW: q, Error: ctx.Err().Error()}
					continue
				}
				reqCtx, reqCancel := context.WithTimeout(ctx, 20*time.Second)
				sugs, fetchErr := fetchSuggestions(reqCtx, client, q, hl, gl)
				reqCancel()
				r := result{KW: q}
				if fetchErr != nil {
					r.Error = fetchErr.Error()
				} else {
					r.Suggestions = sugs
				}
				resultCh <- r
			}
		}()
	}
	go func() {
		workers.Wait()
		close(resultCh)
	}()

	for r := range resultCh {
		processed++
		if r.Error != "" {
			errors++
		}
		if encoder != nil {
			ioMu.Lock()
			_ = encoder.Encode(r)
			ioMu.Unlock()
		}
		if r.Error == "" {
			for _, s := range r.Suggestions {
				addSuggestion(s)
			}
		}
		remaining := atomic.AddInt64(&pending, -1)
		if progressEvery > 0 && processed%int64(progressEvery) == 0 {
			ioMu.Lock()
			fmt.Fprintf(os.Stderr, "seed=%q requests=%d suggestions=%d pending=%d errors=%d\n", seed, processed, atomic.LoadInt64(&printed), remaining, errors)
			ioMu.Unlock()
		}
		if remaining == 0 {
			queue.Close()
			cancel()
		}
	}

	return suggestions, processed, errors
}

func minInt(a, b int) int {
	if a < b {
		return a
	}
	return b
}

func initialQueries(seed string, includeBase bool, alphaSuffix bool, digitSuffix bool) []string {
	seed = strings.TrimSpace(seed)
	if seed == "" {
		return nil
	}
	qs := make([]string, 0, 64)
	if includeBase {
		qs = append(qs, seed)
	}
	if alphaSuffix {
		for c := 'a'; c <= 'z'; c++ {
			qs = append(qs, seed+" "+string(c))
		}
	}
	if digitSuffix {
		for c := '0'; c <= '9'; c++ {
			qs = append(qs, seed+" "+string(c))
		}
	}
	if len(qs) == 0 {
		qs = append(qs, seed)
	}
	return qs
}

func splitKeywords(line string) []string {
	line = strings.TrimSpace(line)
	if line == "" {
		return nil
	}
	parts := strings.FieldsFunc(line, func(r rune) bool {
		switch r {
		case ',', ';', '|', '\t':
			return true
		default:
			return false
		}
	})
	for i := range parts {
		parts[i] = strings.TrimSpace(parts[i])
	}
	return parts
}

func loadConfig(path string) (appConfig, error) {
	path = strings.TrimSpace(path)
	if path == "" {
		return appConfig{}, nil
	}
	b, err := os.ReadFile(path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return appConfig{}, nil
		}
		return appConfig{}, err
	}
	var cfg appConfig
	if err := json.Unmarshal(b, &cfg); err != nil {
		return appConfig{}, err
	}
	return cfg, nil
}

func findArgValue(name, def string) string {
	args := os.Args[1:]
	for i := 0; i < len(args); i++ {
		a := args[i]
		if a == name {
			if i+1 < len(args) {
				return args[i+1]
			}
			return def
		}
		if strings.HasPrefix(a, name+"=") {
			return strings.TrimPrefix(a, name+"=")
		}
	}
	return def
}

func defaultTransport() (*http.Transport, error) {
	base, ok := http.DefaultTransport.(*http.Transport)
	if !ok {
		return nil, errors.New("unexpected default transport")
	}
	return base.Clone(), nil
}

func parseHTTPProxy(s string) (*url.URL, error) {
	s = strings.TrimSpace(s)
	if s == "" {
		return nil, errors.New("proxy is empty")
	}
	if strings.Contains(s, "://") {
		u, err := url.Parse(s)
		if err != nil {
			return nil, err
		}
		if u.Scheme == "" || u.Host == "" {
			return nil, errors.New("proxy URL must include scheme and host")
		}
		return u, nil
	}

	if idx := strings.LastIndex(s, "@"); idx > 0 {
		auth := s[:idx]
		hostPort := s[idx+1:]
		hp := strings.SplitN(hostPort, ":", 2)
		if len(hp) != 2 || hp[0] == "" || hp[1] == "" {
			return nil, errors.New("proxy must be user:pass@host:port")
		}
		user, pass, ok := strings.Cut(auth, ":")
		if !ok || user == "" || pass == "" {
			return nil, errors.New("proxy must be user:pass@host:port")
		}
		u := &url.URL{Scheme: "http", Host: hp[0] + ":" + hp[1]}
		u.User = url.UserPassword(user, pass)
		return u, nil
	}

	parts := strings.Split(s, ":")
	if len(parts) == 2 {
		host := strings.TrimSpace(parts[0])
		port := strings.TrimSpace(parts[1])
		if host == "" || port == "" {
			return nil, errors.New("proxy must be host:port")
		}
		return &url.URL{Scheme: "http", Host: host + ":" + port}, nil
	}
	if len(parts) >= 4 {
		host := strings.TrimSpace(parts[0])
		port := strings.TrimSpace(parts[1])
		user := parts[2]
		pass := strings.Join(parts[3:], ":")
		if host == "" || port == "" {
			return nil, errors.New("proxy must be host:port:user:pass")
		}
		u := &url.URL{Scheme: "http", Host: host + ":" + port}
		u.User = url.UserPassword(user, pass)
		return u, nil
	}

	return nil, errors.New("proxy must be host:port or host:port:user:pass or user:pass@host:port")
}

func readNonEmptyLines(path string) ([]string, error) {
	path = strings.TrimSpace(path)
	if path == "" {
		return nil, nil
	}
	f, err := os.Open(path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil, nil
		}
		return nil, err
	}
	defer f.Close()

	lines := make([]string, 0, 8)
	scanner := bufio.NewScanner(f)
	scanner.Buffer(make([]byte, 1024), 1024*1024)
	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())
		if line == "" {
			continue
		}
		lines = append(lines, line)
	}
	if err := scanner.Err(); err != nil {
		return nil, err
	}
	return lines, nil
}

func fetchSuggestions(ctx context.Context, client *http.Client, kw, hl, gl string) ([]string, error) {
	endpoint, err := url.Parse("https://www.google.com/complete/search")
	if err != nil {
		return nil, err
	}

	q := endpoint.Query()
	q.Set("cp", "1")
	q.Set("client", "gws-wiz")
	q.Set("xssi", "t")
	q.Set("gs_pcrt", "undefined")
	q.Set("hl", hl)
	if strings.TrimSpace(gl) != "" {
		q.Set("gl", gl)
	}
	q.Set("authuser", "0")
	q.Set("dpr", "1")
	q.Set("q", kw)
	endpoint.RawQuery = q.Encode()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("User-Agent", "Mozilla/5.0")

	res, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()

	if res.StatusCode < 200 || res.StatusCode >= 300 {
		b, _ := io.ReadAll(io.LimitReader(res.Body, 4096))
		return nil, fmt.Errorf("http %d: %s", res.StatusCode, strings.TrimSpace(string(b)))
	}

	body, err := io.ReadAll(res.Body)
	if err != nil {
		return nil, err
	}

	raw, err := decodeWindows1252(body)
	if err != nil {
		return nil, err
	}

	if strings.HasPrefix(raw, ")]}'") {
		raw = strings.TrimPrefix(raw, ")]}'")
		raw = strings.TrimPrefix(raw, "\n")
	}

	var top any
	if err := json.Unmarshal([]byte(raw), &top); err != nil {
		return nil, err
	}

	arr, ok := top.([]any)
	if !ok || len(arr) == 0 {
		return nil, errors.New("unexpected response shape")
	}

	rawSuggestions, ok := arr[0].([]any)
	if !ok {
		return nil, errors.New("unexpected suggestions shape")
	}

	suggestions := make([]string, 0, len(rawSuggestions))
	for _, item := range rawSuggestions {
		row, ok := item.([]any)
		if !ok || len(row) == 0 {
			continue
		}
		html, _ := row[0].(string)
		clean := cleanSuggestion(html)
		if clean == "" {
			continue
		}
		suggestions = append(suggestions, clean)
	}

	return suggestions, nil
}

func decodeWindows1252(b []byte) (string, error) {
	decoded, err := charmap.Windows1252.NewDecoder().Bytes(b)
	if err != nil {
		return "", err
	}
	return string(decoded), nil
}

func cleanSuggestion(s string) string {
	s = strings.ReplaceAll(s, "<b>", "")
	s = strings.ReplaceAll(s, "</b>", "")
	s = html.UnescapeString(s)
	return filterAllowedRunes(s)
}

func filterAllowedRunes(s string) string {
	var b strings.Builder
	b.Grow(len(s))
	for _, r := range s {
		if isAllowedRune(r) {
			b.WriteRune(r)
		}
	}
	return b.String()
}

func isAllowedRune(r rune) bool {
	if r >= 0x20 && r <= 0x7E {
		return true
	}
	if r >= 0x00C0 && r <= 0x00FF {
		return true
	}
	switch r {
	case '’', '“', '”', '"', '‘', '‐', '–', '—', '…':
		return true
	}
	if r >= 0x4E00 && r <= 0x9FFF {
		return true
	}
	if r >= 0x0600 && r <= 0x06FF {
		return true
	}
	if r >= 0x0900 && r <= 0x097F {
		return true
	}
	return false
}
