package main

import (
	"context"
	"errors"
	"flag"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"regexp"
	"sort"
	"strings"
	"time"
)

const (
	kindCPU = "cpu"
	kindGPU = "gpu"
)

type options struct {
	kind  string
	query string
}

type result struct {
	Kind  string
	Name  string
	URL   string
	Stats []stat
}

type stat struct {
	Name  string
	Value string
}

type client struct {
	http *http.Client
}

var (
	letterNumberRe = regexp.MustCompile(`([a-z])([0-9])`)
	numberLetterRe = regexp.MustCompile(`([0-9])([a-z])`)
	nonWordRe      = regexp.MustCompile(`[^a-z0-9]+`)
	scriptRe       = regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`)
	styleRe        = regexp.MustCompile(`(?is)<style[^>]*>.*?</style>`)
	tagRe          = regexp.MustCompile(`(?s)<[^>]+>`)
	tokenReplacer  = strings.NewReplacer("+", " plus ", "-", " ", "_", " ", "(", " ", ")", " ", "/", " ")
)

func main() {
	opts, err := parseArgs(os.Args[1:])
	if err != nil {
		printUsage(os.Stderr)
		exitErr(err)
	}

	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
	defer cancel()

	c := &client{http: &http.Client{Timeout: 15 * time.Second}}
	res, err := c.passmark(ctx, opts.kind, opts.query)
	if err != nil {
		exitErr(err)
	}

	printResult(os.Stdout, res)
}

func parseArgs(args []string) (options, error) {
	fs := flag.NewFlagSet("passctl", flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	cpu := fs.Bool("cpu", false, "search PassMark CPUs")
	gpu := fs.Bool("gpu", false, "search PassMark GPUs")
	longHelp := fs.Bool("help", false, "show help")
	shortHelp := fs.Bool("h", false, "show help")

	if err := fs.Parse(args); err != nil {
		return options{}, err
	}
	if *shortHelp || *longHelp {
		printUsage(os.Stdout)
		os.Exit(0)
	}
	rest := fs.Args()
	if len(rest) != 1 {
		return options{}, errors.New("expected exactly one CPU or GPU search query")
	}
	var opts options
	if *cpu && *gpu {
		return options{}, errors.New("choose only one target: --cpu or --gpu")
	}
	if *cpu {
		opts.kind = kindCPU
	}
	if *gpu {
		opts.kind = kindGPU
	}
	if opts.kind == "" {
		return options{}, errors.New("missing target: use --cpu or --gpu")
	}
	opts.query = strings.TrimSpace(rest[0])
	if opts.query == "" {
		return options{}, errors.New("query cannot be empty")
	}
	return opts, nil
}

func printUsage(w io.Writer) {
	fmt.Fprintln(w, "Usage:")
	fmt.Fprintln(w, `  passctl --cpu "Ryzen 3600x"`)
	fmt.Fprintln(w, `  passctl --gpu "Radeon RX 6650 XT"`)
	fmt.Fprintln(w)
	fmt.Fprintln(w, "Options:")
	fmt.Fprintln(w, "  --cpu  Search PassMark CPU benchmarks")
	fmt.Fprintln(w, "  --gpu  Search PassMark GPU benchmarks")
}

func exitErr(err error) {
	fmt.Fprintf(os.Stderr, "passctl: %v\n", err)
	os.Exit(1)
}

func printResult(w io.Writer, res result) {
	fmt.Fprintf(w, "PassMark %s: %s\n", strings.ToUpper(res.Kind), res.Name)
	if res.URL != "" {
		fmt.Fprintf(w, "URL: %s\n", res.URL)
	}
	fmt.Fprintln(w)
	width := 0
	for _, s := range res.Stats {
		if len(s.Name) > width {
			width = len(s.Name)
		}
	}
	for _, s := range res.Stats {
		fmt.Fprintf(w, "%-*s  %s\n", width, s.Name+":", s.Value)
	}
}

func (c *client) passmark(ctx context.Context, kind, query string) (result, error) {
	baseHost := "www.cpubenchmark.net"
	listPath := "/cpu-list/all"
	detailPaths := []string{"/cpu.php", "/cpu_lookup.php"}
	if kind == kindGPU {
		baseHost = "www.videocardbenchmark.net"
		listPath = "/gpu_list.php"
		detailPaths = []string{"/gpu.php", "/gpu_lookup.php", "video_lookup.php", "/video_lookup.php"}
	}

	if kind == kindCPU {
		directURL := (&url.URL{Scheme: "https", Host: baseHost, Path: "/cpu.php", RawQuery: "cpu=" + url.QueryEscape(query)}).String()
		if direct, err := c.get(ctx, directURL); err == nil {
			name, stats := parsePassMarkDetail(direct)
			if name != "" && len(stats) > 0 {
				return result{Kind: kind, Name: name, URL: directURL, Stats: stats}, nil
			}
		}
	}
	listURL := (&url.URL{Scheme: "https", Host: baseHost, Path: listPath}).String()
	body, err := c.get(ctx, listURL)
	if err != nil {
		return result{}, err
	}
	candidates := passmarkCandidates(body, baseHost, detailPaths)
	best, ok := bestCandidate(query, candidates)
	if !ok {
		return result{}, fmt.Errorf("no PassMark %s match found for %q", kind, query)
	}
	if len(best.Stats) > 0 {
		return result{Kind: kind, Name: best.Name, URL: best.URL, Stats: best.Stats}, nil
	}

	detail, err := c.get(ctx, best.URL)
	if err != nil {
		return result{}, err
	}
	name, stats := parsePassMarkDetail(detail)
	if name == "" {
		name = best.Name
	}
	if len(stats) == 0 {
		return result{}, fmt.Errorf("PassMark page was fetched but no benchmark stats could be parsed: %s", best.URL)
	}
	return result{Kind: kind, Name: name, URL: best.URL, Stats: stats}, nil
}

func (c *client) get(ctx context.Context, rawURL string) (string, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
	if err != nil {
		return "", err
	}
	req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 passctl/0.1")
	req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
	req.Header.Set("Accept-Language", "en-US,en;q=0.9")

	resp, err := c.http.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
	if err != nil {
		return "", err
	}
	body := string(data)
	if resp.StatusCode >= 400 {
		return "", fmt.Errorf("%s returned %s", rawURL, resp.Status)
	}
	if isBlocked(body) {
		return "", fmt.Errorf("%s returned an anti-bot/challenge page; try again later or from a network/browser session the site accepts", rawURL)
	}
	return body, nil
}

func isBlocked(body string) bool {
	lower := strings.ToLower(body)
	return strings.Contains(lower, "403 forbidden") ||
		strings.TrimSpace(lower) == "forbidden" ||
		strings.Contains(lower, "just a moment") ||
		strings.Contains(lower, "enable javascript and cookies") ||
		strings.Contains(lower, "cf-chl")
}

type candidate struct {
	Name  string
	URL   string
	Stats []stat
}

func passmarkCandidates(body, host string, detailPaths []string) []candidate {
	var pathAlternates []string
	for _, p := range detailPaths {
		pathAlternates = append(pathAlternates, regexp.QuoteMeta(p))
	}
	pattern := fmt.Sprintf(`(?is)<tr[^>]*>\s*<td[^>]*>\s*<a[^>]+href=["']([^"']*(?:%s)\?[^"']*(?:cpu|gpu)=[^"']*(?:&amp;|&)id=\d+[^"']*)["'][^>]*>(.*?)</a>\s*</td>\s*<td[^>]*>(.*?)</td>\s*<td[^>]*>(.*?)</td>\s*<td[^>]*>(.*?)</td>\s*<td[^>]*>(.*?)</td>\s*</tr>`, strings.Join(pathAlternates, "|"))
	re := regexp.MustCompile(pattern)
	seen := make(map[string]bool)
	var out []candidate
	for _, m := range re.FindAllStringSubmatch(body, -1) {
		href := htmlUnescape(m[1])
		name := cleanText(stripTags(m[2]))
		if name == "" {
			if u, err := url.Parse(href); err == nil {
				name = u.Query().Get("cpu")
				if name == "" {
					name = u.Query().Get("gpu")
				}
			}
		}
		if strings.HasPrefix(href, "/") {
			href = "https://" + host + href
		}
		if !strings.HasPrefix(href, "http") {
			href = "https://" + host + "/" + strings.TrimPrefix(href, "/")
		}
		key := strings.ToLower(name + "|" + href)
		if name != "" && !seen[key] {
			seen[key] = true
			stats := []stat{
				{Name: "PassMark Score", Value: cleanText(stripTags(m[3]))},
				{Name: "Rank", Value: cleanText(stripTags(m[4]))},
				{Name: "Value", Value: cleanText(stripTags(m[5]))},
				{Name: "Price", Value: cleanText(stripTags(m[6]))},
			}
			out = append(out, candidate{Name: name, URL: href, Stats: compactStats(stats)})
		}
	}
	return out
}

func compactStats(stats []stat) []stat {
	var out []stat
	for _, s := range stats {
		s.Name = strings.TrimSpace(s.Name)
		s.Value = strings.TrimSpace(s.Value)
		if s.Name == "" || s.Value == "" {
			continue
		}
		out = append(out, s)
	}
	return out
}

func bestCandidate(query string, candidates []candidate) (candidate, bool) {
	if len(candidates) == 0 {
		return candidate{}, false
	}
	normalizedQuery := normalizeTokens(query)
	type scored struct {
		candidate
		score int
	}
	var scoredList []scored
	for _, c := range candidates {
		score := matchScoreNormalized(normalizedQuery, c.Name)
		if score > 0 {
			scoredList = append(scoredList, scored{candidate: c, score: score})
		}
	}
	if len(scoredList) == 0 {
		return candidates[0], true
	}
	sort.SliceStable(scoredList, func(i, j int) bool {
		return scoredList[i].score > scoredList[j].score
	})
	return scoredList[0].candidate, true
}

func matchScore(query, name string) int {
	return matchScoreNormalized(normalizeTokens(query), name)
}

func matchScoreNormalized(q, name string) int {
	n := normalizeTokens(name)
	if q == "" || n == "" {
		return 0
	}
	if q == n {
		return 1000
	}
	score := 0
	if strings.Contains(n, q) {
		score += 500
	}
	for _, tok := range strings.Fields(q) {
		if strings.Contains(n, tok) {
			score += 50 + len(tok)
		}
	}
	return score
}

func normalizeTokens(s string) string {
	s = strings.ToLower(s)
	s = tokenReplacer.Replace(s)
	s = letterNumberRe.ReplaceAllString(s, "$1 $2")
	s = numberLetterRe.ReplaceAllString(s, "$1 $2")
	return strings.Join(strings.Fields(nonWordRe.ReplaceAllString(s, " ")), " ")
}

func parsePassMarkDetail(body string) (string, []stat) {
	name := firstMatch(body, `(?is)<h1[^>]*>(.*?)</h1>`)
	if name == "" {
		name = firstMatch(body, `(?is)<title[^>]*>(.*?)</title>`)
	}
	name = strings.TrimSuffix(cleanText(stripTags(name)), " PassMark CPU Mark")
	name = strings.TrimSuffix(name, " Videocard Benchmarks")

	var stats []stat
	add := func(k, v string) {
		k, v = cleanText(stripTags(k)), cleanText(stripTags(v))
		if k == "" || v == "" || len(k) > 80 || len(v) > 200 {
			return
		}
		if skipPassMarkStat(k, v) {
			return
		}
		stats = appendUnique(stats, stat{Name: k, Value: v})
	}

	for _, s := range passmarkSelectedStats(body) {
		add(s.Name, s.Value)
	}

	directScoreRe := regexp.MustCompile(`(?is)Average\s+(CPU|G3D|G2D)\s+Mark.*?<div[^>]*>\s*([^<]*Rating)\s*</div>\s*<div[^>]*>\s*([0-9,]+)\s*</div>`)
	for _, m := range directScoreRe.FindAllStringSubmatch(body, -1) {
		add("Average "+strings.ToUpper(m[1])+" Mark", m[3])
	}
	singleRe := regexp.MustCompile(`(?is)Single\s+Thread\s+Rating\s*</div>\s*<div[^>]*>\s*([0-9,]+)\s*</div>`)
	if m := singleRe.FindStringSubmatch(body); len(m) == 2 {
		add("Single Thread Rating", m[1])
	}

	rowRe := regexp.MustCompile(`(?is)<tr[^>]*>\s*<t[dh][^>]*>(.*?)</t[dh]>\s*<t[dh][^>]*>(.*?)</t[dh]>`)
	for _, m := range rowRe.FindAllStringSubmatch(body, -1) {
		add(m[1], m[2])
	}

	strongRe := regexp.MustCompile(`(?is)<(?:strong|b)[^>]*>([^:<]{2,80}:?)</(?:strong|b)>\s*([^<\n]{1,200})`)
	for _, m := range strongRe.FindAllStringSubmatch(body, -1) {
		add(strings.TrimSuffix(m[1], ":"), m[2])
	}

	return name, stats
}

func skipPassMarkStat(k, v string) bool {
	lk := strings.ToLower(k)
	lv := strings.ToLower(v)
	switch lk {
	case "merchant", "processor", "baseline":
		return true
	}
	if strings.Contains(k, `"`) || strings.Contains(v, `" +`) {
		return true
	}
	if strings.Contains(lk, "detected and enabled") || strings.Contains(lk, "view current prices") {
		return true
	}
	if strings.Contains(lv, "for our website") || strings.Contains(lv, "selected cpu") {
		return true
	}
	return false
}

func passmarkSelectedStats(body string) []stat {
	id := firstMatch(body, `(?is)var\s+(?:cpu|gpu|videocard)DetailsId\s*=\s*["']?(\d+)`)
	if id == "" {
		id = firstMatch(body, `(?is)["'](?:cpu|gpu|videocard)DetailsId["']\s*:\s*["']?(\d+)`)
	}
	if id == "" {
		return nil
	}

	var stats []stat
	row := firstMatch(body, `(?is)<li[^>]+id=["']pk`+regexp.QuoteMeta(id)+`["'][^>]*>(.*?)</li>`)
	if row != "" {
		if product := cleanText(stripTags(firstMatch(row, `(?is)<span[^>]+class=["'][^"']*prdname[^"']*["'][^>]*>(.*?)</span>`))); product != "" {
			stats = appendUnique(stats, stat{Name: "Matched Product", Value: product})
		}
		if score := cleanText(stripTags(firstMatch(row, `(?is)<span[^>]+class=["'][^"']*mark-neww[^"']*["'][^>]*>(.*?)</span>`))); score != "" {
			stats = appendUnique(stats, stat{Name: "PassMark Score", Value: score})
		}
		if value := cleanText(stripTags(firstMatch(row, `(?is)<span[^>]+class=["'][^"']*count[^"']*["'][^>]*>(.*?)</span>`))); value != "" {
			stats = appendUnique(stats, stat{Name: "Value", Value: value})
		}
		if price := cleanText(stripTags(firstMatch(row, `(?is)<span[^>]+class=["'][^"']*price-neww[^"']*["'][^>]*>(.*?)</span>`))); price != "" {
			stats = appendUnique(stats, stat{Name: "Price", Value: price})
		}
		score := cleanText(stripTags(firstMatch(row, `(?is)<span[^>]+class=["'][^"']*mark-neww[^"']*["'][^>]*>(.*?)</span>`)))
		nums := onclickNumbers(row)
		if len(nums) >= 3 {
			if sameNumber(nums[0], score) {
				stats = appendUnique(stats, stat{Name: "Rank", Value: nums[1]})
				stats = appendUnique(stats, stat{Name: "Samples", Value: nums[2]})
			} else {
				stats = appendUnique(stats, stat{Name: "Rank", Value: nums[0]})
				stats = appendUnique(stats, stat{Name: "Samples", Value: nums[2]})
			}
		}
	}

	rankRow := firstMatch(body, `(?is)<li[^>]+id=["']rk`+regexp.QuoteMeta(id)+`["'][^>]*>(.*?)</li>`)
	if rankRow != "" {
		if score := cleanText(stripTags(firstMatch(rankRow, `(?is)<span[^>]+class=["'][^"']*count[^"']*["'][^>]*>(.*?)</span>`))); score != "" {
			stats = appendUnique(stats, stat{Name: "PassMark Score", Value: score})
		}
	}
	return stats
}

func onclickNumbers(row string) []string {
	payload := firstMatch(row, `(?is)onclick=["'][^(]*\((.*?)\)`)
	if payload == "" {
		return nil
	}
	re := regexp.MustCompile(`['"]?(-?(?:\d{1,3},)*\d+(?:\.\d+)?)['"]?`)
	var nums []string
	for _, m := range re.FindAllStringSubmatch(payload, -1) {
		nums = append(nums, m[1])
	}
	return nums
}

func sameNumber(a, b string) bool {
	clean := func(s string) string {
		s = strings.ReplaceAll(s, ",", "")
		s = strings.TrimSpace(s)
		return strings.TrimSuffix(s, ".0")
	}
	return clean(a) == clean(b)
}

func firstMatch(s, pattern string) string {
	re := regexp.MustCompile(pattern)
	m := re.FindStringSubmatch(s)
	if len(m) < 2 {
		return ""
	}
	return m[1]
}

func stripScripts(s string) string {
	s = scriptRe.ReplaceAllString(s, " ")
	return styleRe.ReplaceAllString(s, " ")
}

func stripTags(s string) string {
	s = stripScripts(s)
	return tagRe.ReplaceAllString(s, " ")
}

func cleanText(s string) string {
	s = htmlUnescape(s)
	s = strings.ReplaceAll(s, "\u00a0", " ")
	return strings.Join(strings.Fields(s), " ")
}

func htmlUnescape(s string) string {
	repl := strings.NewReplacer(
		"&amp;", "&",
		"&lt;", "<",
		"&gt;", ">",
		"&quot;", `"`,
		"&#39;", "'",
		"&#x27;", "'",
		"&nbsp;", " ",
	)
	return repl.Replace(s)
}

func appendUnique(stats []stat, next stat) []stat {
	key := strings.ToLower(next.Name)
	for _, s := range stats {
		if strings.ToLower(s.Name) == key {
			return stats
		}
	}
	return append(stats, next)
}
