package main

import (
	"bufio"
	"bytes"
	"crypto/sha256"
	"embed"
	"encoding/csv"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"runtime"
	"sort"
	"strconv"
	"strings"
	"sync"
	"syscall"
	"time"
)

//go:embed assets/*
var embedded embed.FS

const version = "1.0.14"
const product = "ConCon"
const title = "Connection Control"
const vendor = "Censor Software"

type Conn struct {
	Protocol   string  `json:"protocol"`
	Local      string  `json:"local"`
	Remote     string  `json:"remote"`
	RemoteIP   string  `json:"remote_ip"`
	RemotePort string  `json:"remote_port"`
	State      string  `json:"state"`
	PID        int     `json:"pid"`
	Program    string  `json:"program"`
	Country    string  `json:"country"`
	Org        string  `json:"org"`
	Lat        float64 `json:"lat"`
	Lon        float64 `json:"lon"`
	Risk       string  `json:"risk"`
	RiskScore  int     `json:"risk_score"`
	FirstSeen  string  `json:"first_seen"`
	LastSeen   string  `json:"last_seen"`
	SeenCount  int     `json:"seen_count"`
	FlowLevel  int     `json:"flow_level"`
}

type Geo struct {
	Country, Region, City, Org string
	Lat, Lon                   float64
	Last                       time.Time
}

type ProgramSummary struct {
	Program   string `json:"program"`
	Count     int    `json:"count"`
	UniqueIPs int    `json:"unique_ips"`
	PIDs      []int  `json:"pids"`
}

type CountrySummary struct {
	Country   string `json:"country"`
	Count     int    `json:"count"`
	UniqueIPs int    `json:"unique_ips"`
}

type Alert struct {
	Level  string `json:"level"`
	Title  string `json:"title"`
	Detail string `json:"detail"`
	Time   string `json:"time"`
}

type TrafficStats struct {
	DownloadBps     int64  `json:"download_bps"`
	UploadBps       int64  `json:"upload_bps"`
	TotalDownloaded int64  `json:"total_downloaded"`
	TotalUploaded   int64  `json:"total_uploaded"`
	SampleSeconds   int64  `json:"sample_seconds"`
	Mode            string `json:"mode"`
}

type CaptureStatus struct {
	Method           string `json:"method"`
	LastError        string `json:"last_error"`
	LastRun          string `json:"last_run"`
	RawRows          int    `json:"raw_rows"`
	FilteredRows     int    `json:"filtered_rows"`
	ProcessMapMethod string `json:"process_map_method"`
	TrafficMethod    string `json:"traffic_method"`
}

type Snapshot struct {
	Name             string           `json:"name"`
	Version          string           `json:"version"`
	Vendor           string           `json:"vendor"`
	Now              string           `json:"now"`
	Connections      []Conn           `json:"connections"`
	Programs         []ProgramSummary `json:"programs"`
	Countries        []CountrySummary `json:"countries"`
	Alerts           []Alert          `json:"alerts"`
	Traffic          TrafficStats     `json:"traffic"`
	TotalConnections int              `json:"total_connections"`
	UniqueRemoteIPs  int              `json:"unique_remote_ips"`
	UniquePrograms   int              `json:"unique_programs"`
	CaptureMode      string           `json:"capture_mode"`
	GeoMode          string           `json:"geo_mode"`
	Capture          CaptureStatus    `json:"capture"`
	Note             string           `json:"note"`
}

var (
	mu           sync.RWMutex
	current      []Conn
	seen         = map[string]*Conn{}
	geoCache     = map[string]Geo{}
	alertHistory []Alert
	trafficStats TrafficStats
	capture      CaptureStatus
	lastRx       int64
	lastTx       int64
	lastTrafficT time.Time
	dataDir      string
)

func hiddenCommand(name string, args ...string) *exec.Cmd {
	cmd := exec.Command(name, args...)
	if runtime.GOOS == "windows" {
		cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
	}
	return cmd
}


func main() {
	dataDir = filepath.Join(os.Getenv("ProgramData"), vendor, product)
	if os.Getenv("ProgramData") == "" {
		dataDir = filepath.Join(os.TempDir(), "ConCon")
	}
	os.MkdirAll(dataDir, 0755)
	logFile, _ := os.OpenFile(filepath.Join(dataDir, "concon.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
	if logFile != nil {
		log.SetOutput(io.MultiWriter(os.Stdout, logFile))
	}
	log.Printf("%s %s %s starting", product, title, version)
	go collectorLoop()

	mux := http.NewServeMux()
	mux.HandleFunc("/", index)
	mux.HandleFunc("/api/snapshot", apiSnapshot)
	mux.HandleFunc("/api/export.csv", exportCSV)
	mux.HandleFunc("/api/debug", apiDebug)
	mux.HandleFunc("/assets/concon-logo.png", func(w http.ResponseWriter, r *http.Request) { serveEmbedded(w, "assets/concon-logo.png", "image/png") })
	mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(map[string]string{"status": "ok", "version": version, "product": product})
	})

	addr := "127.0.0.1:8765"
	url := "http://" + addr + "/"
	ln, err := net.Listen("tcp", addr)
	if err != nil {
		log.Printf("Port %s is already in use or unavailable: %v", addr, err)
		if runtime.GOOS == "windows" {
			if killPIDsOnPort("8765") {
				time.Sleep(900 * time.Millisecond)
				ln, err = net.Listen("tcp", addr)
			}
		}
		if err != nil {
			log.Printf("Could not take port %s after cleanup: %v", addr, err)
			openBrowserRobust(url)
			return
		}
	}
	go waitForHealthAndOpen(url)
	log.Println("ConCon dashboard running on " + url)
	log.Fatal(http.Serve(ln, mux))
}

func serveEmbedded(w http.ResponseWriter, name, ct string) {
	b, err := embedded.ReadFile(name)
	if err != nil {
		http.NotFound(w, nil)
		return
	}
	w.Header().Set("Content-Type", ct)
	w.Write(b)
}

func collectorLoop() {
	for {
		conns, err := collectConnections()
		if err != nil {
			log.Println("collector:", err)
			setCaptureError(err)
		}
		now := time.Now()
		updateTrafficStats(now)
		nowS := now.Format(time.RFC3339)
		newAlerts := []Alert{}
		for i := range conns {
			conns[i].LastSeen = nowS
			key := connKey(conns[i])
			if g, ok := geolocate(conns[i].RemoteIP); ok {
				conns[i].Country = g.Country
				conns[i].Org = compactOrg(g)
				conns[i].Lat = g.Lat
				conns[i].Lon = g.Lon
			}
			conns[i].Risk, conns[i].RiskScore = risk(conns[i])
			if old, ok := seen[key]; ok {
				conns[i].FirstSeen = old.FirstSeen
				conns[i].SeenCount = old.SeenCount + 1
			} else {
				conns[i].FirstSeen = nowS
				conns[i].SeenCount = 1
				if conns[i].RemoteIP != "" {
					newAlerts = append(newAlerts, Alert{Level: "info", Title: "Ny ekstern IP", Detail: fmt.Sprintf("%s koblet til %s:%s", conns[i].Program, conns[i].RemoteIP, conns[i].RemotePort), Time: nowS})
				}
			}
			conns[i].FlowLevel = flowLevel(conns[i])
			cp := conns[i]
			seen[key] = &cp
		}
		mu.Lock()
		current = conns
		if len(newAlerts) > 0 {
			alertHistory = append(newAlerts, alertHistory...)
			if len(alertHistory) > 100 {
				alertHistory = alertHistory[:100]
			}
		}
		mu.Unlock()
		appendHistory(conns)
		time.Sleep(2 * time.Second)
	}
}

func setCaptureError(err error) {
	mu.Lock()
	defer mu.Unlock()
	capture.LastError = err.Error()
	capture.LastRun = time.Now().Format(time.RFC3339)
}

func compactOrg(g Geo) string {
	if g.Org != "" {
		return g.Org
	}
	if g.City != "" || g.Region != "" {
		return strings.TrimSpace(g.City + " " + g.Region)
	}
	return ""
}

func connKey(c Conn) string {
	return c.Protocol + "|" + c.RemoteIP + "|" + c.RemotePort + "|" + c.Program + "|" + strconv.Itoa(c.PID)
}

func flowLevel(c Conn) int {
	x := 1
	if strings.EqualFold(c.State, "ESTABLISHED") || strings.EqualFold(c.State, "Established") {
		x += 2
	}
	if c.RemotePort == "443" || c.RemotePort == "80" {
		x++
	}
	if c.SeenCount > 10 {
		x++
	}
	if x > 5 {
		x = 5
	}
	return x
}

func risk(c Conn) (string, int) {
	if c.RemoteIP == "" {
		return "local", 0
	}
	if c.Country == "Unknown" || c.Country == "" {
		return "unknown", 35
	}
	p := c.RemotePort
	if p == "3389" || p == "445" || p == "23" || p == "21" {
		return "elevated", 70
	}
	if c.RiskScore > 0 {
		return c.Risk, c.RiskScore
	}
	return "normal", 10
}

type psTCP struct {
	Protocol      string      `json:"Protocol"`
	LocalAddress  string      `json:"LocalAddress"`
	LocalPort     interface{} `json:"LocalPort"`
	RemoteAddress string      `json:"RemoteAddress"`
	RemotePort    interface{} `json:"RemotePort"`
	State         interface{} `json:"State"`
	OwningProcess interface{} `json:"OwningProcess"`
}

func collectConnections() ([]Conn, error) {
	if runtime.GOOS != "windows" {
		return []Conn{}, nil
	}
	if conns, raw, err := collectPowerShellTCP(); err == nil {
		setCaptureStatus("PowerShell Get-NetTCPConnection", "", raw, len(conns))
		return conns, nil
	} else {
		log.Println("Get-NetTCPConnection fallback:", err)
	}
	conns, raw, err := collectNetstatTCP()
	if err != nil {
		setCaptureStatus("netstat -ano fallback", err.Error(), raw, len(conns))
		return conns, err
	}
	setCaptureStatus("netstat -ano fallback", "", raw, len(conns))
	return conns, nil
}

func setCaptureStatus(method, lastErr string, rawRows, filteredRows int) {
	mu.Lock()
	defer mu.Unlock()
	capture.Method = method
	capture.LastError = lastErr
	capture.LastRun = time.Now().Format(time.RFC3339)
	capture.RawRows = rawRows
	capture.FilteredRows = filteredRows
}

func collectPowerShellTCP() ([]Conn, int, error) {
	pids, procMethod := processMap()
	cmdText := `$ErrorActionPreference='Stop'; Get-NetTCPConnection | Where-Object { $_.RemoteAddress -and $_.RemoteAddress -ne '0.0.0.0' -and $_.RemoteAddress -ne '::' -and $_.RemoteAddress -ne '127.0.0.1' -and $_.RemoteAddress -ne '::1' } | Select-Object @{Name='Protocol';Expression={'TCP'}},LocalAddress,LocalPort,RemoteAddress,RemotePort,State,OwningProcess | ConvertTo-Json -Compress -Depth 3`
	out, err := hiddenCommand("powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", cmdText).Output()
	if err != nil {
		return nil, 0, err
	}
	out = bytes.TrimSpace(out)
	if len(out) == 0 {
		return []Conn{}, 0, nil
	}
	var rows []psTCP
	if out[0] == '[' {
		if err := json.Unmarshal(out, &rows); err != nil {
			return nil, 0, err
		}
	} else {
		var row psTCP
		if err := json.Unmarshal(out, &row); err != nil {
			return nil, 0, err
		}
		rows = []psTCP{row}
	}
	res := make([]Conn, 0, len(rows))
	for _, row := range rows {
		rip := cleanIP(row.RemoteAddress)
		if !validRemote(rip) {
			continue
		}
		pid := toInt(row.OwningProcess)
		rport := strconv.Itoa(toInt(row.RemotePort))
		lport := strconv.Itoa(toInt(row.LocalPort))
		prog := pids[pid]
		if prog == "" {
			prog = fmt.Sprintf("PID %d", pid)
		}
		state := fmt.Sprint(row.State)
		local := cleanIP(row.LocalAddress) + ":" + lport
		remote := rip + ":" + rport
		res = append(res, Conn{Protocol: "TCP", Local: local, Remote: remote, RemoteIP: rip, RemotePort: rport, State: state, PID: pid, Program: prog})
	}
	sortConns(res)
	mu.Lock()
	capture.ProcessMapMethod = procMethod
	mu.Unlock()
	return res, len(rows), nil
}

func collectNetstatTCP() ([]Conn, int, error) {
	pids, procMethod := processMap()
	var res []Conn
	out, err := hiddenCommand("cmd", "/C", "netstat -ano -p tcp").Output()
	if err != nil {
		return nil, 0, err
	}
	raw := 0
	sc := bufio.NewScanner(bytes.NewReader(out))
	for sc.Scan() {
		line := strings.TrimSpace(sc.Text())
		if !strings.HasPrefix(line, "TCP") {
			continue
		}
		raw++
		f := strings.Fields(line)
		if len(f) < 5 {
			continue
		}
		pid, _ := strconv.Atoi(f[len(f)-1])
		rip, rport := splitAddr(f[2])
		rip = cleanIP(rip)
		if !validRemote(rip) {
			continue
		}
		prog := pids[pid]
		if prog == "" {
			prog = fmt.Sprintf("PID %d", pid)
		}
		res = append(res, Conn{Protocol: "TCP", Local: f[1], Remote: f[2], RemoteIP: rip, RemotePort: rport, State: f[3], PID: pid, Program: prog})
	}
	sortConns(res)
	mu.Lock()
	capture.ProcessMapMethod = procMethod
	mu.Unlock()
	return res, raw, nil
}

func sortConns(res []Conn) {
	sort.Slice(res, func(i, j int) bool {
		if res[i].Program == res[j].Program {
			return res[i].RemoteIP < res[j].RemoteIP
		}
		return res[i].Program < res[j].Program
	})
}

func toInt(v interface{}) int {
	switch x := v.(type) {
	case float64:
		return int(x)
	case int:
		return x
	case string:
		i, _ := strconv.Atoi(x)
		return i
	default:
		i, _ := strconv.Atoi(fmt.Sprint(v))
		return i
	}
}

func cleanIP(s string) string {
	s = strings.TrimSpace(s)
	s = strings.Trim(s, "[]")
	if strings.Contains(s, "%") {
		s = strings.SplitN(s, "%", 2)[0]
	}
	return s
}

func validRemote(ip string) bool {
	if ip == "" || ip == "*" || ip == "0.0.0.0" || ip == "127.0.0.1" || ip == "::" || ip == "::1" {
		return false
	}
	parsed := net.ParseIP(ip)
	if parsed == nil {
		return false
	}
	if parsed.IsLoopback() || parsed.IsUnspecified() || parsed.IsMulticast() {
		return false
	}
	if strings.HasPrefix(strings.ToLower(ip), "fe80:") {
		return false
	}
	return true
}

func splitAddr(s string) (string, string) {
	s = strings.TrimSpace(s)
	if strings.HasPrefix(s, "[") {
		idx := strings.LastIndex(s, "]:")
		if idx > 0 {
			return strings.Trim(s[:idx+1], "[]"), s[idx+2:]
		}
	}
	idx := strings.LastIndex(s, ":")
	if idx < 0 {
		return s, ""
	}
	return strings.Trim(s[:idx], "[]"), s[idx+1:]
}

type psProc struct {
	Id          int    `json:"Id"`
	ProcessName string `json:"ProcessName"`
	Path        string `json:"Path"`
}

func processMap() (map[int]string, string) {
	if m, ok := processMapPS(); ok {
		return m, "PowerShell Get-Process"
	}
	return processMapTasklist(), "tasklist fallback"
}

func processMapPS() (map[int]string, bool) {
	m := map[int]string{}
	cmdText := `$ErrorActionPreference='SilentlyContinue'; Get-Process | Select-Object Id,ProcessName,Path | ConvertTo-Json -Compress -Depth 2`
	out, err := hiddenCommand("powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", cmdText).Output()
	if err != nil || len(bytes.TrimSpace(out)) == 0 {
		return m, false
	}
	out = bytes.TrimSpace(out)
	var rows []psProc
	if out[0] == '[' {
		if json.Unmarshal(out, &rows) != nil {
			return m, false
		}
	} else {
		var row psProc
		if json.Unmarshal(out, &row) != nil {
			return m, false
		}
		rows = []psProc{row}
	}
	for _, row := range rows {
		name := row.ProcessName
		if name == "" && row.Path != "" {
			name = filepath.Base(row.Path)
		}
		if name != "" && !strings.HasSuffix(strings.ToLower(name), ".exe") {
			name += ".exe"
		}
		m[row.Id] = name
	}
	return m, len(m) > 0
}

func processMapTasklist() map[int]string {
	m := map[int]string{}
	out, err := hiddenCommand("cmd", "/C", "tasklist /fo csv /nh").Output()
	if err != nil {
		return m
	}
	r := csv.NewReader(bytes.NewReader(out))
	r.FieldsPerRecord = -1
	rows, _ := r.ReadAll()
	for _, row := range rows {
		if len(row) >= 2 {
			pid, _ := strconv.Atoi(strings.TrimSpace(row[1]))
			m[pid] = row[0]
		}
	}
	return m
}

func geolocate(ip string) (Geo, bool) {
	if ip == "" {
		return Geo{}, false
	}
	parsed := net.ParseIP(ip)
	if parsed == nil {
		return Geo{}, false
	}
	if parsed.IsPrivate() || parsed.IsLoopback() {
		return Geo{Country: "Local/private", Org: "Private network", Lat: 66.3128, Lon: 14.142, Last: time.Now()}, true
	}
	if g, ok := geoCache[ip]; ok && time.Since(g.Last) < 24*time.Hour {
		return g, true
	}
	if !geoLookupEnabled() {
		g := deterministicGeo(ip, "GeoIP disabled by setting - estimated")
		geoCache[ip] = g
		return g, true
	}

	if g, ok := geolocateKnownNetwork(ip); ok {
		geoCache[ip] = g
		return g, true
	}
	if g, ok := geolocateIPWhoIs(ip); ok {
		geoCache[ip] = g
		return g, true
	}
	if g, ok := geolocateIPAPI(ip); ok {
		geoCache[ip] = g
		return g, true
	}
	if g, ok := geolocateIPInfo(ip); ok {
		geoCache[ip] = g
		return g, true
	}

	g := deterministicGeo(ip, "GeoIP lookup failed - estimated")
	geoCache[ip] = g
	return g, true
}

func geoLookupEnabled() bool {
	v := strings.ToLower(strings.TrimSpace(os.Getenv("CONCON_GEOLOOKUP")))
	return !(v == "0" || v == "false" || v == "off" || v == "no" || v == "disabled")
}


func geolocateKnownNetwork(ip string) (Geo, bool) {
	parsed := net.ParseIP(ip)
	if parsed == nil || parsed.To4() == nil {
		return Geo{}, false
	}
	// Fast local hints for large anycast/CDN networks. External GeoIP still takes precedence
	// where available; this prevents random fallback positions for common services when
	// a firewall blocks lookup APIs. Coordinates are representative network locations.
	type hint struct{ cidr, country, org string; lat, lon float64 }
	hints := []hint{
		{"104.16.0.0/12", "United States", "Cloudflare anycast network", 37.7749, -122.4194},
		{"172.64.0.0/13", "United States", "Cloudflare anycast network", 37.7749, -122.4194},
		{"162.158.0.0/15", "United States", "Cloudflare anycast network", 37.7749, -122.4194},
		{"188.114.96.0/20", "United States", "Cloudflare anycast network", 37.7749, -122.4194},
		{"8.8.8.0/24", "United States", "Google DNS", 37.3861, -122.0839},
		{"8.8.4.0/24", "United States", "Google DNS", 37.3861, -122.0839},
		{"1.1.1.0/24", "Australia", "Cloudflare/APNIC DNS", -33.8688, 151.2093},
	}
	for _, h := range hints {
		_, n, err := net.ParseCIDR(h.cidr)
		if err == nil && n.Contains(parsed) {
			return Geo{Country: h.country, Org: h.org + " · local network hint", Lat: h.lat, Lon: h.lon, Last: time.Now()}, true
		}
	}
	return Geo{}, false
}

func killPIDsOnPort(port string) bool {
	out, err := hiddenCommand("cmd", "/C", "netstat -ano -p tcp | findstr :"+port).Output()
	if err != nil || len(bytes.TrimSpace(out)) == 0 {
		return false
	}
	self := os.Getpid()
	killed := false
	sc := bufio.NewScanner(bytes.NewReader(out))
	for sc.Scan() {
		f := strings.Fields(sc.Text())
		if len(f) < 5 { continue }
		pid, err := strconv.Atoi(f[len(f)-1])
		if err != nil || pid == 0 || pid == self { continue }
		log.Printf("Stopping process on ConCon port %s: PID %d", port, pid)
		_ = hiddenCommand("cmd", "/C", "taskkill /PID "+strconv.Itoa(pid)+" /F").Run()
		killed = true
	}
	return killed
}

func geolocateIPWhoIs(ip string) (Geo, bool) {
	client := http.Client{Timeout: 2600 * time.Millisecond}
	resp, err := client.Get("https://ipwho.is/" + ip + "?fields=success,country,region,city,latitude,longitude,connection")
	if err != nil {
		return Geo{}, false
	}
	defer resp.Body.Close()
	var v struct {
		Success    bool    `json:"success"`
		Country    string  `json:"country"`
		Region     string  `json:"region"`
		City       string  `json:"city"`
		Latitude   float64 `json:"latitude"`
		Longitude  float64 `json:"longitude"`
		Connection struct {
			Org string `json:"org"`
			ISP string `json:"isp"`
		} `json:"connection"`
	}
	if json.NewDecoder(resp.Body).Decode(&v) != nil || !v.Success {
		return Geo{}, false
	}
	org := v.Connection.Org
	if org == "" {
		org = v.Connection.ISP
	}
	if v.Latitude == 0 && v.Longitude == 0 {
		return Geo{}, false
	}
	return Geo{Country: v.Country, Region: v.Region, City: v.City, Org: org, Lat: v.Latitude, Lon: v.Longitude, Last: time.Now()}, true
}

func geolocateIPAPI(ip string) (Geo, bool) {
	client := http.Client{Timeout: 2600 * time.Millisecond}
	resp, err := client.Get("http://ip-api.com/json/" + ip + "?fields=status,country,regionName,city,org,lat,lon")
	if err != nil {
		return Geo{}, false
	}
	defer resp.Body.Close()
	var v struct {
		Status, Country, RegionName, City, Org string
		Lat, Lon                               float64
	}
	if json.NewDecoder(resp.Body).Decode(&v) != nil || v.Status != "success" || (v.Lat == 0 && v.Lon == 0) {
		return Geo{}, false
	}
	return Geo{Country: v.Country, Region: v.RegionName, City: v.City, Org: v.Org, Lat: v.Lat, Lon: v.Lon, Last: time.Now()}, true
}


func geolocateIPInfo(ip string) (Geo, bool) {
	client := http.Client{Timeout: 2600 * time.Millisecond}
	resp, err := client.Get("https://ipinfo.io/" + ip + "/json")
	if err != nil { return Geo{}, false }
	defer resp.Body.Close()
	var v struct { Country string `json:"country"`; Region string `json:"region"`; City string `json:"city"`; Org string `json:"org"`; Loc string `json:"loc"` }
	if json.NewDecoder(resp.Body).Decode(&v) != nil || v.Loc == "" { return Geo{}, false }
	parts := strings.Split(v.Loc, ",")
	if len(parts) != 2 { return Geo{}, false }
	lat, err1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
	lon, err2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
	if err1 != nil || err2 != nil { return Geo{}, false }
	country := v.Country
	if country == "" { country = "GeoIP" }
	return Geo{Country: country, Region: v.Region, City: v.City, Org: v.Org, Lat: lat, Lon: lon, Last: time.Now()}, true
}

func deterministicGeo(ip, org string) Geo {
	h := fnv(ip)
	lat := -48.0 + float64(h%9600)/100.0
	lon := -170.0 + float64((h>>8)%34000)/100.0
	return Geo{Country: "Estimated", Org: org, Lat: lat, Lon: lon, Last: time.Now()}
}

func fnv(s string) uint32 {
	var h uint32 = 2166136261
	for i := 0; i < len(s); i++ {
		h ^= uint32(s[i])
		h *= 16777619
	}
	return h
}

var numberRe = regexp.MustCompile(`[0-9][0-9.,\s]*`)

func updateTrafficStats(now time.Time) {
	rx, tx, mode, ok := collectInterfaceCounters()
	if !ok {
		mu.Lock()
		trafficStats.Mode = "interface counters unavailable"
		capture.TrafficMethod = trafficStats.Mode
		mu.Unlock()
		return
	}
	mu.Lock()
	defer mu.Unlock()
	if !lastTrafficT.IsZero() && rx >= lastRx && tx >= lastTx {
		dt := now.Sub(lastTrafficT).Seconds()
		if dt > 0 {
			trafficStats.DownloadBps = int64(float64(rx-lastRx) / dt)
			trafficStats.UploadBps = int64(float64(tx-lastTx) / dt)
			trafficStats.SampleSeconds = int64(dt)
		}
	} else {
		trafficStats.DownloadBps = 0
		trafficStats.UploadBps = 0
		trafficStats.SampleSeconds = 0
	}
	trafficStats.TotalDownloaded = rx
	trafficStats.TotalUploaded = tx
	trafficStats.Mode = mode
	capture.TrafficMethod = mode
	lastRx, lastTx, lastTrafficT = rx, tx, now
}

type psAdapterStat struct {
	ReceivedBytes interface{} `json:"ReceivedBytes"`
	SentBytes     interface{} `json:"SentBytes"`
	Name          string      `json:"Name"`
}

func collectInterfaceCounters() (int64, int64, string, bool) {
	if runtime.GOOS != "windows" {
		return 0, 0, "not windows", false
	}
	if rx, tx, ok := collectAdapterStatsPS(); ok {
		return rx, tx, "PowerShell Get-NetAdapterStatistics", true
	}
	if rx, tx, ok := collectNetstatE(); ok {
		return rx, tx, "netstat -e fallback", true
	}
	return 0, 0, "unavailable", false
}

func collectAdapterStatsPS() (int64, int64, bool) {
	cmdText := `$ErrorActionPreference='Stop'; Get-NetAdapterStatistics | Select-Object Name,ReceivedBytes,SentBytes | ConvertTo-Json -Compress -Depth 2`
	out, err := hiddenCommand("powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", cmdText).Output()
	if err != nil || len(bytes.TrimSpace(out)) == 0 {
		return 0, 0, false
	}
	out = bytes.TrimSpace(out)
	var rows []psAdapterStat
	if out[0] == '[' {
		if json.Unmarshal(out, &rows) != nil {
			return 0, 0, false
		}
	} else {
		var row psAdapterStat
		if json.Unmarshal(out, &row) != nil {
			return 0, 0, false
		}
		rows = []psAdapterStat{row}
	}
	var rx, tx int64
	for _, r := range rows {
		rx += int64(toInt(r.ReceivedBytes))
		tx += int64(toInt(r.SentBytes))
	}
	return rx, tx, rx > 0 || tx > 0
}

func collectNetstatE() (int64, int64, bool) {
	out, err := hiddenCommand("cmd", "/C", "netstat -e").Output()
	if err != nil {
		return 0, 0, false
	}
	sc := bufio.NewScanner(bytes.NewReader(out))
	for sc.Scan() {
		line := strings.TrimSpace(sc.Text())
		if line == "" {
			continue
		}
		nums := numberRe.FindAllString(line, -1)
		if len(nums) >= 2 {
			rx, ok1 := parseCounter(nums[0])
			tx, ok2 := parseCounter(nums[1])
			if ok1 && ok2 && (rx > 0 || tx > 0) {
				return rx, tx, true
			}
		}
	}
	return 0, 0, false
}

func parseCounter(s string) (int64, bool) {
	repl := strings.NewReplacer(",", "", ".", "", " ", "", "\t", "")
	v, err := strconv.ParseInt(repl.Replace(s), 10, 64)
	return v, err == nil
}

func snapshot() Snapshot {
	mu.RLock()
	cc := append([]Conn(nil), current...)
	alerts := append([]Alert(nil), alertHistory...)
	traffic := trafficStats
	cap := capture
	mu.RUnlock()
	ipSet := map[string]bool{}
	progIP := map[string]map[string]bool{}
	progCount := map[string]int{}
	progPids := map[string]map[int]bool{}
	countryIP := map[string]map[string]bool{}
	countryCount := map[string]int{}
	for _, c := range cc {
		if c.RemoteIP != "" {
			ipSet[c.RemoteIP] = true
		}
		progCount[c.Program]++
		if progIP[c.Program] == nil {
			progIP[c.Program] = map[string]bool{}
		}
		if c.RemoteIP != "" {
			progIP[c.Program][c.RemoteIP] = true
		}
		if progPids[c.Program] == nil {
			progPids[c.Program] = map[int]bool{}
		}
		if c.PID > 0 {
			progPids[c.Program][c.PID] = true
		}
		country := c.Country
		if country == "" {
			country = "Unknown"
		}
		countryCount[country]++
		if countryIP[country] == nil {
			countryIP[country] = map[string]bool{}
		}
		if c.RemoteIP != "" {
			countryIP[country][c.RemoteIP] = true
		}
	}
	programs := make([]ProgramSummary, 0, len(progCount))
	for p, n := range progCount {
		pids := []int{}
		for pid := range progPids[p] {
			pids = append(pids, pid)
		}
		sort.Ints(pids)
		programs = append(programs, ProgramSummary{Program: p, Count: n, UniqueIPs: len(progIP[p]), PIDs: pids})
	}
	sort.Slice(programs, func(i, j int) bool { return programs[i].Count > programs[j].Count })
	countries := make([]CountrySummary, 0, len(countryCount))
	for c, n := range countryCount {
		countries = append(countries, CountrySummary{Country: c, Count: n, UniqueIPs: len(countryIP[c])})
	}
	sort.Slice(countries, func(i, j int) bool { return countries[i].Count > countries[j].Count })
	if len(alerts) > 25 {
		alerts = alerts[:25]
	}
	geoMode := "external GeoIP enabled"
	if !geoLookupEnabled() {
		geoMode = "offline estimated GeoIP"
	}
	note := "ConCon leser aktive TCP-tilkoblinger via Get-NetTCPConnection med netstat-fallback. Speedometer bruker Get-NetAdapterStatistics med netstat -e fallback. Per-IP byte-tellere krever Enhanced Capture/WinDivert/WFP."
	return Snapshot{Name: product, Version: version, Vendor: vendor, Now: time.Now().Format(time.RFC3339), Connections: cc, Programs: programs, Countries: countries, Alerts: alerts, Traffic: traffic, TotalConnections: len(cc), UniqueRemoteIPs: len(ipSet), UniquePrograms: len(progCount), CaptureMode: cap.Method, GeoMode: geoMode, Capture: cap, Note: note}
}

func apiSnapshot(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(snapshot())
}
func apiDebug(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	s := snapshot()
	json.NewEncoder(w).Encode(map[string]interface{}{"snapshot": s, "data_dir": dataDir, "log_file": filepath.Join(dataDir, "concon.log"), "tips": []string{"Åpne nettsider eller strøm video for å skape aktive TCP-tilkoblinger.", "Hvis total_connections fortsatt er 0: kjør ConCon-console.exe og send loggen.", "GeoIP er aktivert som standard. Sett CONCON_GEOLOOKUP=0 for å slå av eksterne oppslag.", "For nøyaktig per-IP trafikkmengde kreves Enhanced Capture-modul."}})
}
func exportCSV(w http.ResponseWriter, r *http.Request) {
	s := snapshot()
	w.Header().Set("Content-Type", "text/csv")
	w.Header().Set("Content-Disposition", "attachment; filename=concon-connections.csv")
	cw := csv.NewWriter(w)
	defer cw.Flush()
	cw.Write([]string{"protocol", "program", "pid", "local", "remote_ip", "remote_port", "state", "country", "org", "lat", "lon", "risk", "first_seen", "last_seen", "seen_count"})
	for _, c := range s.Connections {
		cw.Write([]string{c.Protocol, c.Program, strconv.Itoa(c.PID), c.Local, c.RemoteIP, c.RemotePort, c.State, c.Country, c.Org, fmt.Sprint(c.Lat), fmt.Sprint(c.Lon), c.Risk, c.FirstSeen, c.LastSeen, strconv.Itoa(c.SeenCount)})
	}
}
func index(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	b, err := embedded.ReadFile("assets/index.html")
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	w.Write(b)
}

func waitForHealthAndOpen(url string) {
	health := strings.TrimRight(url, "/") + "/api/health"
	client := http.Client{Timeout: 350 * time.Millisecond}
	for i := 0; i < 40; i++ {
		resp, err := client.Get(health)
		if err == nil {
			resp.Body.Close()
			if resp.StatusCode >= 200 && resp.StatusCode < 500 {
				time.Sleep(250 * time.Millisecond)
				openBrowserRobust(url)
				return
			}
		}
		time.Sleep(250 * time.Millisecond)
	}
	openBrowserRobust(url)
}
func openBrowserRobust(url string) {
	if runtime.GOOS == "windows" {
		// Avoid cmd /C start. It can flash a console window and, on some systems,
		// repeatedly open command windows while the collector is active.
		if err := hiddenCommand("rundll32", "url.dll,FileProtocolHandler", url).Start(); err == nil {
			return
		}
		if err := hiddenCommand("explorer", url).Start(); err == nil {
			return
		}
		return
	}
	if runtime.GOOS == "darwin" {
		hiddenCommand("open", url).Start()
		return
	}
	hiddenCommand("xdg-open", url).Start()
}
func appendHistory(conns []Conn) {
	f, err := os.OpenFile(filepath.Join(dataDir, "history.csv"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
	if err != nil {
		return
	}
	defer f.Close()
	cw := csv.NewWriter(f)
	defer cw.Flush()
	now := time.Now().Format(time.RFC3339)
	for _, c := range conns {
		if c.RemoteIP != "" {
			cw.Write([]string{now, c.Program, strconv.Itoa(c.PID), c.Protocol, c.RemoteIP, c.RemotePort, c.State, c.Country, c.Org, c.Risk})
		}
	}
}
func shaFile(path string) (string, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", err
	}
	defer f.Close()
	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		return "", err
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}
