// Package identity provides consistent anti-detection identity profiles.
//
// Every request uses a complete, internally-consistent identity profile where
// UA + TLS fingerprint + header order + OS + hardware + screen + locale + geo
// all match. Random UA without matching TLS is worse than no rotation.
package identity

import (
	"encoding/json"
	"fmt"
	"log/slog"
	"math/rand/v2"
	"strings"
	"sync"
)

// noGeoConstraintWarnOnce ensures the "no geo constraint" warning fires at
// most once per process. The warning fires when Generate is called without
// any of WithCountry, WithProxy, WithGeoResolver, WithLocale, WithTimezone,
// or WithGeo — meaning the resulting identity will pick a random locale
// (87% en-US given the embedded profile distribution) regardless of the
// proxy exit IP, which is a first-tier bot-detection signal when the
// profile is paired with a non-US proxy.
var noGeoConstraintWarnOnce sync.Once

// Browser represents a supported browser type.
type Browser string

const (
	BrowserFirefox Browser = "firefox"
	BrowserChrome  Browser = "chrome"
)

// OS represents a supported operating system.
type OS string

const (
	OSWindows OS = "windows"
	OSMacOS   OS = "macos"
	OSLinux   OS = "linux"
)

// Profile contains a complete, internally-consistent identity for a scraping session.
// All attributes are guaranteed to match each other: UA matches browser+OS,
// TLS profile matches browser version, header order matches browser, GPU matches OS,
// screen resolution is common for OS, timezone matches proxy geo, etc.
type Profile struct {
	// Browser identification
	UA          string  `json:"ua"`
	BrowserName Browser `json:"browser_name"`
	BrowserVer  string  `json:"browser_ver"`

	// TLS fingerprint profile that MATCHES the browser
	TLSProfile string `json:"tls_profile"`

	// Header ordering specific to this browser version
	HeaderOrder []string `json:"header_order"`

	// Operating system
	OS        OS     `json:"os"`
	OSVersion string `json:"os_version"`
	Platform  string `json:"platform"`

	// Hardware (consistent with OS)
	Cores  int     `json:"cores"`
	Memory float64 `json:"memory"`
	GPU    string  `json:"gpu"`

	// Screen (common resolution for this OS)
	ScreenW    int     `json:"screen_w"`
	ScreenH    int     `json:"screen_h"`
	ColorDepth int     `json:"color_depth"`
	PixelRatio float64 `json:"pixel_ratio"`

	// Locale (matched to proxy geo)
	Languages []string `json:"languages"`
	Timezone  string   `json:"timezone"`
	Locale    string   `json:"locale"`

	// Geo (derived from proxy IP)
	Lat float64 `json:"lat"`
	Lng float64 `json:"lng"`

	// Camoufox environment configuration
	CamoufoxEnv map[string]string `json:"camoufox_env,omitempty"`
}

// LocalePolicy controls how the Locale and Languages fields are set relative
// to proxy geo resolution.
type LocalePolicy int

const (
	// LocalePolicyProxyGeo (default) — locale and languages are derived from
	// the proxy exit IP or country constraint, matching geo-based anti-detection
	// principle #6. This is the existing behavior; it is not changed.
	LocalePolicyProxyGeo LocalePolicy = iota

	// LocalePolicyEnglishDefault — locale is forced to "en-US" and languages
	// to ["en-US", "en"] regardless of proxy geo. Use this when scraping
	// English-language content (SERP, English news, etc.) through a proxy in a
	// non-English-speaking country. Timezone and geo coordinates are still
	// derived from the proxy geo (physical location stays coherent; only
	// language preference is overridden).
	//
	// Example:
	//
	//   id := identity.Generate(
	//       identity.WithCountry("RU"),
	//       identity.WithLocalePolicy(identity.LocalePolicyEnglishDefault),
	//   )
	//   // id.Locale == "en-US", id.Timezone == "Europe/Moscow"
	LocalePolicyEnglishDefault
)

// Option is a functional option for configuring identity generation.
type Option func(*generateConfig)

type generateConfig struct {
	browser        Browser
	os             OS
	proxyIP        string
	country        string
	lat            float64
	lng            float64
	tz             string
	locale         string
	langs          []string
	geoResolver    GeoResolver
	localePolicy   LocalePolicy
	localeExplicit bool // true only when set via WithLocale; geo-derived locale does NOT set this
}

// WithBrowser constrains identity generation to a specific browser.
func WithBrowser(b Browser) Option {
	return func(c *generateConfig) {
		c.browser = b
	}
}

// WithOS constrains identity generation to a specific operating system.
func WithOS(o OS) Option {
	return func(c *generateConfig) {
		c.os = o
	}
}

// WithProxy sets the proxy IP for geo-matching of timezone, locale, and languages.
func WithProxy(ip string) Option {
	return func(c *generateConfig) {
		c.proxyIP = ip
	}
}

// WithGeo sets explicit geo coordinates for the identity.
func WithGeo(lat, lng float64) Option {
	return func(c *generateConfig) {
		c.lat = lat
		c.lng = lng
	}
}

// WithTimezone sets an explicit timezone for the identity.
func WithTimezone(tz string) Option {
	return func(c *generateConfig) {
		c.tz = tz
	}
}

// WithCountry constrains the identity to a specific country by looking up the
// built-in geo table (or the configured GeoResolver).  It is a convenience
// alternative to WithProxy when the caller knows the country directly.
// If the country code is unknown the option is silently ignored.
func WithCountry(code string) Option {
	return func(c *generateConfig) {
		c.country = strings.ToUpper(code)
	}
}

// WithGeoResolver plugs in a custom GeoResolver so callers can use a real
// MaxMind database or any external geo service instead of the built-in table.
// The resolver is called during Generate when WithProxy or WithCountry is set.
func WithGeoResolver(r GeoResolver) Option {
	return func(c *generateConfig) {
		c.geoResolver = r
	}
}

// WithLocale sets an explicit locale and languages for the identity.
func WithLocale(locale string, langs ...string) Option {
	return func(c *generateConfig) {
		c.locale = locale
		c.langs = langs
		c.localeExplicit = true // distinguishes from geo-derived locale in applyGeoToConfig
	}
}

// WithLocalePolicy sets the locale assignment policy for the generated identity.
//
// The default policy is LocalePolicyProxyGeo (locale matches proxy IP geo —
// existing behavior, unchanged). Pass LocalePolicyEnglishDefault when scraping
// English-language content through proxies in non-English-speaking countries to
// avoid locale-query mismatch detection.
//
// Note: an explicit WithLocale call takes precedence over any locale policy.
func WithLocalePolicy(policy LocalePolicy) Option {
	return func(c *generateConfig) {
		c.localePolicy = policy
	}
}

// Generate creates a new consistent identity profile. All attributes are guaranteed
// to be internally consistent. Use functional options to constrain the generation.
func Generate(opts ...Option) *Profile {
	cfg := &generateConfig{
		browser: BrowserFirefox,
	}
	for _, opt := range opts {
		opt(cfg)
	}

	// Warn once per process if the caller provided no geo constraint at all.
	// Without WithCountry / WithProxy / WithLocale / WithTimezone / WithGeo /
	// WithGeoResolver, the identity falls back to whatever locale the random
	// device profile carries — overwhelmingly en-US — which mismatches any
	// non-US proxy exit IP and is a bot-detection signal.
	if cfg.country == "" && cfg.proxyIP == "" && cfg.geoResolver == nil &&
		cfg.locale == "" && cfg.tz == "" && cfg.lat == 0 && cfg.lng == 0 {
		noGeoConstraintWarnOnce.Do(func() {
			slog.Warn("identity: no geo constraint provided; locale will not match proxy exit IP — pass WithCountry(code) or WithProxy(ip) to Generate to align identity locale with proxy geo")
		})
	}

	// Pick random OS if not specified
	if cfg.os == "" {
		oses := []OS{OSWindows, OSMacOS, OSLinux}
		cfg.os = oses[rand.IntN(len(oses))]
	}

	// Build the profile key to look up from embedded database
	key := string(cfg.browser) + "_" + string(cfg.os)
	profiles := getProfiles(key)

	var base deviceProfile
	if len(profiles) > 0 {
		base = profiles[rand.IntN(len(profiles))]
	} else {
		base = fallbackProfile(cfg.browser, cfg.os)
	}

	// Build the consistent profile
	p := &Profile{
		BrowserName: cfg.browser,
		BrowserVer:  base.BrowserVer,
		OS:          cfg.os,
		OSVersion:   base.OSVersion,
		Platform:    platformForOS(cfg.os),
		Cores:       base.Cores,
		Memory:      base.Memory,
		GPU:         base.GPU,
		ScreenW:     base.ScreenW,
		ScreenH:     base.ScreenH,
		ColorDepth:  base.ColorDepth,
		PixelRatio:  base.PixelRatio,
	}

	// Set UA to match browser + OS + version exactly
	p.UA = buildUA(cfg.browser, p.BrowserVer, cfg.os, p.OSVersion)

	// Set TLS profile to match browser version
	p.TLSProfile = string(cfg.browser) + "_" + p.BrowserVer

	// Set header order for this browser
	p.HeaderOrder = headerOrderForBrowser(cfg.browser)

	// Resolve geo from proxy IP or country code when not explicitly overridden.
	applyGeoToConfig(cfg)

	// Set locale/geo from proxy or defaults
	if cfg.locale != "" {
		p.Locale = cfg.locale
		p.Languages = cfg.langs
		if len(p.Languages) == 0 {
			p.Languages = []string{cfg.locale, cfg.locale[:2]}
		}
	} else {
		p.Locale = base.Locale
		p.Languages = base.Languages
	}

	if cfg.tz != "" {
		p.Timezone = cfg.tz
	} else {
		p.Timezone = base.Timezone
	}

	if cfg.lat != 0 || cfg.lng != 0 {
		p.Lat = cfg.lat
		p.Lng = cfg.lng
	} else {
		p.Lat = base.Lat
		p.Lng = base.Lng
	}

	// Apply locale policy AFTER geo-based resolution so that timezone and
	// geo coordinates remain proxy-matched. An explicit WithLocale call
	// (cfg.localeExplicit == true) takes precedence over any policy; a locale
	// derived by applyGeoToConfig (geo match) does not.
	if cfg.localePolicy == LocalePolicyEnglishDefault && !cfg.localeExplicit {
		p.Locale = "en-US"
		p.Languages = []string{"en-US", "en"}
	}

	// Build Camoufox environment vars
	p.CamoufoxEnv = buildCamoufoxEnv(p)

	return p
}

// platformForOS returns the navigator.platform value for the given OS.
func platformForOS(o OS) string {
	switch o {
	case OSWindows:
		return "Win32"
	case OSMacOS:
		return "MacIntel"
	case OSLinux:
		return "Linux x86_64"
	default:
		return "Win32"
	}
}

// buildUA constructs a User-Agent string matching the browser, version, and OS.
func buildUA(browser Browser, ver string, o OS, osVer string) string {
	var osPart string
	switch o {
	case OSWindows:
		osPart = fmt.Sprintf("Windows NT %s; Win64; x64", osVer)
	case OSMacOS:
		osPart = fmt.Sprintf("Macintosh; Intel Mac OS X %s", osVer)
	case OSLinux:
		osPart = "X11; Linux x86_64"
	default:
		osPart = "Windows NT 10.0; Win64; x64"
	}

	switch browser {
	case BrowserFirefox:
		return fmt.Sprintf("Mozilla/5.0 (%s; rv:%s) Gecko/20100101 Firefox/%s", osPart, ver, ver)
	case BrowserChrome:
		return fmt.Sprintf("Mozilla/5.0 (%s) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36", osPart, ver)
	default:
		return fmt.Sprintf("Mozilla/5.0 (%s; rv:%s) Gecko/20100101 Firefox/%s", osPart, ver, ver)
	}
}

// headerOrderForBrowser returns the canonical header ordering for the given browser.
func headerOrderForBrowser(browser Browser) []string {
	switch browser {
	case BrowserFirefox:
		return firefoxHeaderOrder
	case BrowserChrome:
		return chromeHeaderOrder
	default:
		return firefoxHeaderOrder
	}
}

var firefoxHeaderOrder = []string{
	"Host",
	"User-Agent",
	"Accept",
	"Accept-Language",
	"Accept-Encoding",
	"DNT",
	"Upgrade-Insecure-Requests",
	"Sec-Fetch-Dest",
	"Sec-Fetch-Mode",
	"Sec-Fetch-Site",
	"Sec-Fetch-User",
	"TE",
	"Priority",
	"Pragma",
	"Cache-Control",
}

var chromeHeaderOrder = []string{
	"Host",
	"Connection",
	"sec-ch-ua",
	"sec-ch-ua-mobile",
	"sec-ch-ua-platform",
	"Upgrade-Insecure-Requests",
	"User-Agent",
	"Accept",
	"Sec-Fetch-Site",
	"Sec-Fetch-Mode",
	"Sec-Fetch-User",
	"Sec-Fetch-Dest",
	"Accept-Encoding",
	"Accept-Language",
	"Priority",
}

// BuildCamoufoxConfig builds the Camoufox fingerprint configuration as a
// flat map suitable for JSON-encoding into CAMOU_CONFIG_N env vars.
//
// Camoufox expects all fingerprint overrides in a single JSON object split
// across CAMOU_CONFIG_1, CAMOU_CONFIG_2, ... environment variables (max ~2000
// chars each due to Windows env-var limits). The JSON uses dot-path keys like
// "screen.width", "navigator.userAgent", etc.
//
// IMPORTANT: Only set properties we want to CONTROL (identity consistency).
// BrowserForge auto-populates unset properties with realistic statistical
// distributions. Do NOT manually set: webGl:*, fonts, canvas:*, voices,
// shaderPrecisionFormats — BrowserForge handles these better than we can.
func (p *Profile) BuildCamoufoxConfig() map[string]any {
	config := map[string]any{
		// Navigator — our identity (must match exactly)
		"navigator.userAgent":            p.UA,
		"navigator.platform":             p.Platform,
		"navigator.hardwareConcurrency":  p.Cores,
		"navigator.oscpu":                buildOscpu(p),
		"navigator.appVersion":           buildAppVersion(p),
		"navigator.doNotTrack":           "unspecified",
		"navigator.globalPrivacyControl": false,

		// Screen — from our profile
		"screen.width":       p.ScreenW,
		"screen.height":      p.ScreenH,
		"screen.availWidth":  p.ScreenW,
		"screen.availHeight": screenAvailHeight(p),
		"screen.colorDepth":  p.ColorDepth,
		"screen.pixelDepth":  p.ColorDepth,

		// Window — derive from screen
		"window.outerWidth":       p.ScreenW,
		"window.outerHeight":      p.ScreenH,
		"window.devicePixelRatio": p.PixelRatio,
		"window.screenX":          0,
		"window.screenY":          0,
		"window.history.length":   1,

		// Geo/Locale
		"timezone": p.Timezone,
	}

	// Navigator language (primary + list)
	if len(p.Languages) > 0 {
		config["navigator.language"] = p.Languages[0]
		config["navigator.languages"] = p.Languages
	}

	// Locale for Camoufox C++ level
	if len(p.Languages) > 0 {
		config["locale:language"] = extractLang(p.Languages[0])
		if region := extractRegion(p.Languages[0]); region != "" {
			config["locale:region"] = region
		}
	}

	// Geolocation
	if p.Lat != 0 || p.Lng != 0 {
		config["geolocation:latitude"] = p.Lat
		config["geolocation:longitude"] = p.Lng
	}

	// WebRTC mDNS obfuscation — hide LAN IP (192.168.x.x) behind
	// <uuid>.local in ICE candidate SDP. Modern Firefox defaults this to
	// true but Camoufox's preset may omit or override it.
	config["media.peerconnection.ice.obfuscate_host_addresses"] = true

	// Accept-Language HTTP header — explicitly pin to a valid BCP-47 string
	// derived from the identity's Languages field.
	//
	// This is CRITICAL: Camoufox's geoip=True resolves the proxy IP's country
	// and joins it with the random identity profile's primary language, producing
	// frankenlocale strings like "en-DE" or "fr-DE" (English/French-in-Germany).
	// No real browser user has this. Anti-bot vendors (Google pre-WAF,
	// PerimeterX) detect it trivially. By pinning Accept-Language here,
	// Camoufox uses our correct value instead of its geoip-derived one.
	// (Source: Phase 6 + 6b HAR capture confirming en-DE/fr-DE as root cause.)
	//
	// Key is "headers.Accept-Language" (with "headers." prefix) per Camoufox's
	// dot-path config schema. The reference_camoufox.md memory listed it as
	// just "Accept-Language" — that was incomplete; the Python wrapper schema
	// uses the "headers." prefix (confirmed via camoufox.utils._load_properties).
	config["headers.Accept-Language"] = canonicalAcceptLanguage(p.Languages)

	// DO NOT add:
	// - webGl:* (BrowserForge generates realistic WebGL params automatically)
	// - fonts (Camoufox bundles 200-600 OS-specific fonts automatically)
	// - canvas:* (Camoufox has built-in canvas anti-fingerprinting)
	// - voices (auto-generated by BrowserForge)
	// - shaderPrecisionFormats (auto-generated by BrowserForge)

	return config
}

// screenAvailHeight returns screen.availHeight accounting for OS taskbar.
func screenAvailHeight(p *Profile) int {
	switch p.OS {
	case OSWindows:
		return p.ScreenH - 40 // Windows taskbar
	case OSMacOS:
		return p.ScreenH - 25 // macOS menu bar
	case OSLinux:
		return p.ScreenH - 28 // GNOME/KDE panel
	default:
		return p.ScreenH - 40
	}
}

// extractLang extracts the language code from a locale string (e.g. "en-US" -> "en").
func extractLang(locale string) string {
	if parts := strings.SplitN(locale, "-", 2); len(parts) > 0 {
		return parts[0]
	}
	return locale
}

// extractRegion extracts the region code from a locale string (e.g. "en-US" -> "US").
func extractRegion(locale string) string {
	if parts := strings.SplitN(locale, "-", 2); len(parts) == 2 {
		return parts[1]
	}
	return ""
}

// canonicalAcceptLanguage builds a valid BCP-47 Accept-Language header value
// from the profile's Languages list, using Firefox-accurate quality factors.
//
// This function is the single source of truth for the Accept-Language that
// Camoufox will send. It MUST NOT produce frankenlocale strings like "en-DE"
// (English-in-Germany) — those are the primary bot-detection signal exploited
// by Google's pre-WAF filter and PerimeterX (confirmed in Phase 6 + 6b HAR
// captures). Each entry in langs must be an independently valid BCP-47 tag
// (e.g. "de-DE", "de", "en") — they are used verbatim, not joined with unrelated
// country codes.
//
// Rules:
//   - Empty langs → "en-US,en;q=0.9" (safe default)
//   - Single tag  → returned as-is
//   - English-primary list (all langs start with "en") → use Firefox's q=0.9
//     pattern without an extra English fallback
//   - Non-English primary → use q=0.9/0.8 for language entries, then append
//     "en;q=0.5" as English fallback (mirrors real Firefox behaviour for most
//     non-English users)
func canonicalAcceptLanguage(langs []string) string {
	if len(langs) == 0 {
		return "en-US,en;q=0.9"
	}
	if len(langs) == 1 {
		return langs[0]
	}

	// Determine whether the primary language is English.
	primaryIsEnglish := strings.HasPrefix(strings.ToLower(langs[0]), "en")

	// Check if the list already contains an English fallback tag.
	hasEnglishFallback := false
	for _, l := range langs {
		if strings.HasPrefix(strings.ToLower(l), "en") {
			hasEnglishFallback = true
			break
		}
	}

	// Build the quality-factor string for the caller-supplied entries.
	// Firefox quality factors (multi-language, n>=2):
	//   [0]: no q (implicit q=1.0)
	//   [1]: q=0.9
	//   [2]: q=0.8
	//   [3]: q=0.7
	//   …
	var b strings.Builder
	b.WriteString(langs[0])
	qFactor := 0.9
	for _, lang := range langs[1:] {
		fmt.Fprintf(&b, ",%s;q=%.1f", lang, qFactor)
		qFactor -= 0.1
		if qFactor < 0.1 {
			qFactor = 0.1
		}
	}

	// Append "en;q=0.5" for non-English primaries that don't already include
	// any English tag. Most non-English Firefox users carry English as fallback.
	if !primaryIsEnglish && !hasEnglishFallback {
		fmt.Fprintf(&b, ",en;q=0.5")
	}

	return b.String()
}

// BuildCamoufoxEnv marshals the Camoufox config to JSON and chunks it into
// CAMOU_CONFIG_1, CAMOU_CONFIG_2, ... environment variables.
func (p *Profile) BuildCamoufoxEnv() map[string]string {
	config := p.BuildCamoufoxConfig()
	configJSON, err := json.Marshal(config)
	if err != nil {
		// Fallback: return empty map (should never happen with valid profiles)
		return map[string]string{}
	}
	return chunkCamoufoxConfig(string(configJSON))
}

// MergeCamoufoxConfig merges additional config (e.g. addons) into the
// profile's Camoufox config, re-marshals to JSON, and returns chunked
// CAMOU_CONFIG_N env vars. This is used by the browser launcher to combine
// fingerprint config with addon config in a single CAMOU_CONFIG blob.
func (p *Profile) MergeCamoufoxConfig(extra map[string]any) map[string]string {
	config := p.BuildCamoufoxConfig()
	for k, v := range extra {
		config[k] = v
	}
	configJSON, err := json.Marshal(config)
	if err != nil {
		return map[string]string{}
	}
	return chunkCamoufoxConfig(string(configJSON))
}

// chunkCamoufoxConfig splits a JSON string into CAMOU_CONFIG_1, CAMOU_CONFIG_2, ...
// env vars. Each chunk is at most 2000 bytes (Windows env var limit).
func chunkCamoufoxConfig(jsonStr string) map[string]string {
	const chunkSize = 2000
	result := make(map[string]string)
	for i := 0; i < len(jsonStr); i += chunkSize {
		end := i + chunkSize
		if end > len(jsonStr) {
			end = len(jsonStr)
		}
		key := fmt.Sprintf("CAMOU_CONFIG_%d", (i/chunkSize)+1)
		result[key] = jsonStr[i:end]
	}
	return result
}

// buildCamoufoxEnv is the internal wrapper that populates Profile.CamoufoxEnv.
// It delegates to BuildCamoufoxEnv which produces proper CAMOU_CONFIG_N vars.
func buildCamoufoxEnv(p *Profile) map[string]string {
	return p.BuildCamoufoxEnv()
}

// buildOscpu returns the navigator.oscpu value matching the profile's OS.
func buildOscpu(p *Profile) string {
	switch p.OS {
	case OSWindows:
		return "Windows NT " + p.OSVersion
	case OSMacOS:
		// macOS uses underscore in version for oscpu (e.g. "Intel Mac OS X 14_0")
		return "Intel Mac OS X " + p.OSVersion
	case OSLinux:
		return "Linux x86_64"
	default:
		return "Windows NT 10.0"
	}
}

// buildAppVersion returns the navigator.appVersion value.
// Firefox always starts with "5.0 (" followed by the oscpu string.
func buildAppVersion(p *Profile) string {
	return "5.0 (" + buildOscpu(p) + ")"
}

// NOTE: addWebGLConfig, addFontConfig, and OS font lists have been REMOVED.
// BrowserForge auto-generates realistic WebGL params, fonts, and canvas noise.
// Manually setting these was worse than letting BrowserForge handle them because
// our static values could be fingerprinted as a consistent cluster.

// AddWebRTCConfig adds WebRTC IP override to the config when a proxy exit IP
// is known. This prevents WebRTC from leaking the real IP address.
func AddWebRTCConfig(config map[string]any, proxyExitIP string) {
	if proxyExitIP != "" {
		config["webrtc:ipv4"] = proxyExitIP
		config["webrtc:ipv6"] = ""
	}
}
