package main

import (
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"os"
	"strconv"
	"sync"
	"time"

	"github.com/joho/godotenv"
	"github.com/oschwald/geoip2-golang"
	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promhttp"
)

/*
 📦 AdGuard Exporter for Prometheus
 ----------------------------------
 Author   : @znand-dev
 License  : MIT
 Repo     : https://github.com/znand-dev/adguardexporter

 This Go application fetches stats from AdGuard Home via API endpoints
 and exposes them as Prometheus metrics at `/metrics`.

 Required ENV variables:
 - ADGUARD_HOST        : AdGuard Home base URL (e.g. http://192.168.1.1:3000)
 - ADGUARD_USER        : API username (your adguard user)
 - ADGUARD_PASS        : API password (your adguard pass)
 - EXPORTER_PORT       : Port to expose metrics (default: 9617)
 - SCRAPE_INTERVAL     : Interval (in seconds) to fetch new stats (default: 15)
 - LOG_LEVEL           : Logging level (options: DEBUG, INFO, WARN, ERROR — default: INFO)
 - GEOIP_DB            : GeoLite2-City.mmdb
*/

var logLevelMap = map[string]int{"ERROR": 1, "WARN": 2, "INFO": 3, "DEBUG": 4}
var currentLogLevel = 3

func initLogger() {
	level := os.Getenv("LOG_LEVEL")
	if level == "" {
		level = "INFO"
	}
	if val, ok := logLevelMap[level]; ok {
		currentLogLevel = val
	}
}

func logX(level string, format string, args ...interface{}) {
	if logLevelMap[level] <= currentLogLevel {
		log.Printf("[%s] %s", level, fmt.Sprintf(format, args...))
	}
}

//function hash query
func buildQueryKey(client, domain, upstream, reason, elapsed string) string {
	return client + "|" + domain + "|" + upstream + "|" + reason + "|" + elapsed
}

type GeoCache struct {
	Country string
	Lat     float64
	Lon     float64
	TS      int64
}

var (
	geoDB    *geoip2.Reader
	geoCache = map[string]GeoCache{}
	geoMutex sync.RWMutex
	// cache query hash
	querySeen   = map[string]int64{}
	queryMutex  sync.Mutex
	queryTTL    = int64(300) // 5 menit
	geoTTL      = int64(86400) // 24 jam
	dedupHits   int64
)

type AdGuardStats struct {
	NumDNSQueries       float64              `json:"num_dns_queries"`
	NumBlockedFiltering float64              `json:"num_blocked_filtering"`
	NumReplacedParental float64              `json:"num_replaced_parental"`
	AvgProcessingTime   float64              `json:"avg_processing_time"`
	TopQueriedDomains   []map[string]float64 `json:"top_queried_domains"`
	TopBlockedDomains   []map[string]float64 `json:"top_blocked_domains"`
	TopClients          []map[string]float64 `json:"top_clients"`
	TopUpstream         []map[string]float64 `json:"top_upstreams_responses"`
	TopUpstreamTime     []map[string]float64 `json:"top_upstreams_avg_time"`
}

type AdGuardStatus struct {
	Version                    string   `json:"version"`
	Language                   string   `json:"language"`
	DNSAddresses               []string `json:"dns_addresses"`
	DNSPort                    int      `json:"dns_port"`
	HTTPPort                   int      `json:"http_port"`
	ProtectionDisabledDuration int      `json:"protection_disabled_duration"`
	ProtectionEnabled          bool     `json:"protection_enabled"`
	DHCPAvailable              bool     `json:"dhcp_available"`
	Running                    bool     `json:"running"`
}

type AdGuardQueryLog struct {
	Data []struct {
		Question struct {
			Type string `json:"type"`
			Name string `json:"name"`
		} `json:"question"`
		Answer   []interface{} `json:"answer"`
		Reason   string        `json:"reason"`
		Client   string        `json:"client"`
		Elapsed  string        `json:"elapsedMs"`
		Upstream string        `json:"upstream"`

		ClientInfo struct {
			Whois struct {
				Country string `json:"country"`
				OrgName string `json:"orgname"`
			} `json:"whois"`
		} `json:"client_info"`

	} `json:"data"`
}

var (

	dnsQueries = prometheus.NewGauge(prometheus.GaugeOpts{
		Name: "adguard_dns_queries_total", Help: "Total DNS queries received",
	})

	blockedFiltering = prometheus.NewGauge(prometheus.GaugeOpts{
		Name: "adguard_blocked_filtering_total", Help: "Total DNS queries blocked",
	})

	replacedParental = prometheus.NewGauge(prometheus.GaugeOpts{
		Name: "adguard_replaced_parental", Help: "Total parental replaced queries",
	})

	avgProcessingTime = prometheus.NewGauge(prometheus.GaugeOpts{
		Name: "adguard_avg_processing_time", Help: "Avg DNS processing time",
	})

	statusProtectionEnabled = prometheus.NewGauge(prometheus.GaugeOpts{
		Name: "adguard_protection_enabled", Help: "Protection enabled (1/0)",
	})

	statusRunning = prometheus.NewGauge(prometheus.GaugeOpts{
		Name: "adguard_running", Help: "AdGuard running",
	})

	statusDHCPAvailable = prometheus.NewGauge(prometheus.GaugeOpts{
		Name: "adguard_dhcp_available", Help: "DHCP available",
	})

	statusDisabledDuration = prometheus.NewGauge(prometheus.GaugeOpts{
		Name: "adguard_protection_disabled_duration_seconds",
		Help: "Time since protection disabled",
	})

	versionInfo = prometheus.NewGaugeVec(
		prometheus.GaugeOpts{
			Name: "adguard_version_info",
			Help: "AdGuard version",
		},
		[]string{"version"},
	)

	topQueriedDomains = prometheus.NewGaugeVec(
		prometheus.GaugeOpts{
			Name: "adguard_top_queried_domain_total",
			Help: "Top queried domains",
		},
		[]string{"domain"},
	)

	topBlockedDomains = prometheus.NewGaugeVec(
		prometheus.GaugeOpts{
			Name: "adguard_top_blocked_domain_total",
			Help: "Top blocked domains",
		},
		[]string{"domain"},
	)

	topClients = prometheus.NewGaugeVec(
		prometheus.GaugeOpts{
			Name: "adguard_top_client_total",
			Help: "Top client IPs",
		},
		[]string{"client"},
	)

	topUpstreams = prometheus.NewGaugeVec(
		prometheus.GaugeOpts{
			Name: "adguard_top_upstream_total",
			Help: "Top upstream servers",
		},
		[]string{"upstream"},
	)

	topUpstreamTime = prometheus.NewGaugeVec(
		prometheus.GaugeOpts{
			Name: "adguard_upstream_avg_response_time_seconds",
			Help: "Avg response time per upstream",
		},
		[]string{"upstream"},
	)

	queryCountByReason = prometheus.NewCounterVec(
		prometheus.CounterOpts{
			Name: "adguard_query_reason_total",
			Help: "Queries by reason",
		},
		[]string{"reason"},
	)

	queryCountByType = prometheus.NewCounterVec(
		prometheus.CounterOpts{
			Name: "adguard_query_type_total",
			Help: "Queries by DNS type",
		},
		[]string{"type"},
	)

	queryHistogramByClient = prometheus.NewHistogramVec(
		prometheus.HistogramOpts{
			Name:    "adguard_query_elapsed_ms",
			Help:    "Query duration by client",
			Buckets: prometheus.LinearBuckets(1, 5, 10),
		},
		[]string{"client"},
	)

	queryCountByUpstream = prometheus.NewCounterVec(
		prometheus.CounterOpts{
			Name: "adguard_query_upstream_total",
			Help: "Queries per upstream",
		},
		[]string{"upstream"},
	)

	queryCountByDomain = prometheus.NewCounterVec(
		prometheus.CounterOpts{
			Name: "adguard_query_domain_total",
			Help: "Queries per domain",
		},
		[]string{"domain"},
	)

	queryCountClientReason = prometheus.NewCounterVec(
		prometheus.CounterOpts{
			Name: "adguard_query_client_reason_total",
			Help: "Queries per client per reason",
		},
		[]string{"client", "reason"},
	)

	clientGeoQueries = prometheus.NewCounterVec(
		prometheus.CounterOpts{
			Name: "adguard_client_geo_queries",
			Help: "Queries per client with geographic info",
		},
		[]string{"client", "country", "lat", "lon"},
	)

    blockedGeoQueries = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "adguard_blocked_geo_queries",
            Help: "Blocked DNS queries per client with geographic info",
        },
        []string{"client", "country", "lat", "lon"},
    )
    
    upstreamLatencyHistogram = prometheus.NewHistogramVec(
	    prometheus.HistogramOpts{
		    Name: "adguard_upstream_latency_seconds",
		    Help: "Latency distribution per upstream DNS server",
		    Buckets: prometheus.ExponentialBuckets(
			    0.001, // 1ms
			    2,
			    10,
		    ),
	    },
	    []string{"upstream"},
    )

	exporterUp = prometheus.NewGauge(
	    prometheus.GaugeOpts{
		    Name: "adguard_exporter_up",
		    Help: "Exporter scrape success",
	    },
    )

    exporterScrapeDuration = prometheus.NewGauge(
	    prometheus.GaugeOpts{
		    Name: "adguard_exporter_scrape_duration_seconds",
		    Help: "Exporter scrape duration",
	   },
    )

    exporterErrors = prometheus.NewCounter(
	    prometheus.CounterOpts{
		    Name: "adguard_exporter_scrape_errors_total",
		    Help: "Total exporter errors",
	   },
    )

	exporterQueryCacheSize = prometheus.NewGauge(
	    prometheus.GaugeOpts{
		    Name: "adguard_exporter_query_cache_size",
		    Help: "Current number of entries in query deduplication cache",
	    },
    )

    exporterGeoCacheSize = prometheus.NewGauge(
	    prometheus.GaugeOpts{
		    Name: "adguard_exporter_geo_cache_size",
		    Help: "Current number of cached GeoIP entries",
	    },
    )

    exporterDedupHits = prometheus.NewCounter(
	    prometheus.CounterOpts{
		    Name: "adguard_exporter_dedup_hits_total",
		    Help: "Total number of duplicate queries skipped by deduplication",
	   },
    )

	queryCountByISP = prometheus.NewCounterVec(
	    prometheus.CounterOpts{
		    Name: "adguard_query_isp_total",
		    Help: "DNS queries grouped by ISP organization",
	    },
	    []string{"isp", "country"},
    )
)

func init() {
	_ = godotenv.Load()
	initLogger()

	prometheus.MustRegister(
		dnsQueries,
		blockedFiltering,
		replacedParental,
		avgProcessingTime,
		statusProtectionEnabled,
		statusRunning,
		statusDHCPAvailable,
		statusDisabledDuration,
		versionInfo,
		topQueriedDomains,
		topBlockedDomains,
		topClients,
		topUpstreams,
		topUpstreamTime,
		queryCountByReason,
		queryCountByType,
		queryHistogramByClient,
		queryCountByUpstream,
		queryCountByDomain,
		queryCountClientReason,
		clientGeoQueries,
        blockedGeoQueries,
		exporterUp,
        exporterScrapeDuration,
        exporterErrors,
		upstreamLatencyHistogram,
		exporterQueryCacheSize,
        exporterGeoCacheSize,
        exporterDedupHits,
		queryCountByISP,
	)
}

func resolveGeo(ipStr string) (GeoCache, bool) {
	if geoDB == nil {
		return GeoCache{}, false
	}

	ip := net.ParseIP(ipStr)
	if ip == nil || ip.IsPrivate() || ip.IsLoopback() {
		return GeoCache{}, false
	}

	now := time.Now().Unix()

	geoMutex.RLock()
	if val, ok := geoCache[ipStr]; ok {

		// cache valid
		if now-val.TS < geoTTL {
			geoMutex.RUnlock()
			return val, true
		}

	}
	geoMutex.RUnlock()

	record, err := geoDB.City(ip)
	if err != nil {
		return GeoCache{}, false
	}

	cache := GeoCache{
		Country: record.Country.IsoCode,
		Lat:     record.Location.Latitude,
		Lon:     record.Location.Longitude,
		TS:      now,
	}

	geoMutex.Lock()
	geoCache[ipStr] = cache
	geoMutex.Unlock()

	return cache, true
}

func fetchQueryLog() (*AdGuardQueryLog, error) {

	host := os.Getenv("ADGUARD_HOST")
	user := os.Getenv("ADGUARD_USER")
	pass := os.Getenv("ADGUARD_PASS")

	url := host + "/control/querylog"

	req, _ := http.NewRequest("GET", url, nil)
	req.SetBasicAuth(user, pass)

	client := &http.Client{Timeout: 10 * time.Second}

	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}

        defer func() {
            if err := resp.Body.Close(); err != nil {
                logX("WARN", "failed to close response body: %v", err)
            }
        }()

	body, err := io.ReadAll(resp.Body)
    if err != nil {
        return nil, err
    }

	var logData AdGuardQueryLog

	err = json.Unmarshal(body, &logData)

	return &logData, err
}

func fetchStats() (*AdGuardStats, error) {

	host := os.Getenv("ADGUARD_HOST")
	user := os.Getenv("ADGUARD_USER")
	pass := os.Getenv("ADGUARD_PASS")

	url := host + "/control/stats"

	req, _ := http.NewRequest("GET", url, nil)
	req.SetBasicAuth(user, pass)

	client := &http.Client{Timeout: 10 * time.Second}

	resp, err := client.Do(req)

	if err != nil {
		return nil, err
	}

	defer func() {
		if err := resp.Body.Close(); err != nil {
			logX("WARN", "failed to close response body: %v", err)
		}
	}()

	body, err := io.ReadAll(resp.Body)
    if err != nil {
        return nil, err
    }

	var stats AdGuardStats

	err = json.Unmarshal(body, &stats)

	return &stats, err
}

func fetchStatus() (*AdGuardStatus, error) {

	host := os.Getenv("ADGUARD_HOST")
	user := os.Getenv("ADGUARD_USER")
	pass := os.Getenv("ADGUARD_PASS")

	url := host + "/control/status"

	req, _ := http.NewRequest("GET", url, nil)
	req.SetBasicAuth(user, pass)

	client := &http.Client{Timeout: 10 * time.Second}

	resp, err := client.Do(req)

	if err != nil {
		return nil, err
	}

	defer func() {
		if err := resp.Body.Close(); err != nil {
			logX("WARN", "failed to close response body: %v", err)
		}
	}()

	body, err := io.ReadAll(resp.Body)
    if err != nil {
        return nil, err
    }

	var status AdGuardStatus

	err = json.Unmarshal(body, &status)

	return &status, err
}

func updateStatsMetrics() {

	stats, err := fetchStats()

	if err != nil {
		logX("ERROR", "Failed fetch stats: %v", err)
		return
	}

	dnsQueries.Set(stats.NumDNSQueries)
	blockedFiltering.Set(stats.NumBlockedFiltering)
	replacedParental.Set(stats.NumReplacedParental)
	avgProcessingTime.Set(stats.AvgProcessingTime)

	// reset gauge vectors
	topQueriedDomains.Reset()
	topBlockedDomains.Reset()
	topClients.Reset()
	topUpstreams.Reset()
	topUpstreamTime.Reset()

	// top queried domains
	for _, d := range stats.TopQueriedDomains {
		for domain, count := range d {
			topQueriedDomains.WithLabelValues(domain).Set(count)
		}
	}

	// top blocked domains
	for _, d := range stats.TopBlockedDomains {
		for domain, count := range d {
			topBlockedDomains.WithLabelValues(domain).Set(count)
		}
	}

	// top clients
	for _, c := range stats.TopClients {
		for client, count := range c {
			topClients.WithLabelValues(client).Set(count)
		}
	}

	// upstream responses
	for _, u := range stats.TopUpstream {
		for upstream, count := range u {
			topUpstreams.WithLabelValues(upstream).Set(count)
		}
	}

	// upstream avg response time
	for _, u := range stats.TopUpstreamTime {
		for upstream, time := range u {
			topUpstreamTime.WithLabelValues(upstream).Set(time)
		}
	}

	logX(
		"DEBUG",
		"Fetched stats: queries=%.0f blocked=%.0f replaced=%.0f avgTime=%.2fms topDomains=%d",
		stats.NumDNSQueries,
		stats.NumBlockedFiltering,
		stats.NumReplacedParental,
		stats.AvgProcessingTime,
		len(stats.TopQueriedDomains),
	)
}

func updateStatusMetrics() {

	status, err := fetchStatus()

	if err != nil {
		logX("ERROR", "Failed fetch status: %v", err)
		return
	}

	if status.Running {
		statusRunning.Set(1)
	} else {
		statusRunning.Set(0)
	}

	if status.ProtectionEnabled {
		statusProtectionEnabled.Set(1)
	} else {
		statusProtectionEnabled.Set(0)
	}

	if status.DHCPAvailable {
		statusDHCPAvailable.Set(1)
	} else {
		statusDHCPAvailable.Set(0)
	}

	statusDisabledDuration.Set(float64(status.ProtectionDisabledDuration))

	versionInfo.WithLabelValues(status.Version).Set(1)

	logX(
		"DEBUG",
		"Fetched status: running=%v protection=%v DHCP=%v version=%s",
		status.Running,
		status.ProtectionEnabled,
		status.DHCPAvailable,
		status.Version,
	)
}

func updateQueryLogMetrics() {

	scanned := 0
	processed := 0
	skipped := 0
	geoResolved := 0
	privateClients := 0
        publicClients := 0

	logData, err := fetchQueryLog()

	if err != nil {
		logX("ERROR", "Failed querylog: %v", err)
		return
	}

	for _, q := range logData.Data {

		scanned++
               
                ip := net.ParseIP(q.Client)

                if ip != nil {
	            if ip.IsPrivate() || ip.IsLoopback() {
		        privateClients++
	            } else {
		        publicClients++
	            }
                
                }
		
                // dedup logic
		key := buildQueryKey(
			q.Client,
			q.Question.Name,
			q.Upstream,
			q.Reason,
			q.Elapsed,
		)

		now := time.Now().Unix()

		queryMutex.Lock()

		if ts, exists := querySeen[key]; exists {
			if now-ts < queryTTL {
				queryMutex.Unlock()
				skipped++
				exporterDedupHits.Inc()
				continue
			}
		}

		querySeen[key] = now
		queryMutex.Unlock()
		
		processed++

		queryCountByReason.WithLabelValues(q.Reason).Inc()
		queryCountByType.WithLabelValues(q.Question.Type).Inc()

		elapsedMs, err := strconv.ParseFloat(q.Elapsed, 64)

		if err == nil {

			queryHistogramByClient.WithLabelValues(q.Client).Observe(elapsedMs)

			if q.Upstream != "" {
				upstreamLatencyHistogram.WithLabelValues(q.Upstream).Observe(elapsedMs / 1000)
			}
		}

		queryCountByUpstream.WithLabelValues(q.Upstream).Inc()
		queryCountByDomain.WithLabelValues(q.Question.Name).Inc()
		queryCountClientReason.WithLabelValues(q.Client, q.Reason).Inc()

		geo, ok := resolveGeo(q.Client)

		if ok {

			geoResolved++

			clientGeoQueries.WithLabelValues(
				q.Client,
				geo.Country,
				fmt.Sprintf("%f", geo.Lat),
				fmt.Sprintf("%f", geo.Lon),
			).Inc()

			if q.Reason == "FilteredBlackList" ||
				q.Reason == "FilteredSafeBrowsing" ||
				q.Reason == "FilteredParental" {

				blockedGeoQueries.WithLabelValues(
					q.Client,
					geo.Country,
					fmt.Sprintf("%f", geo.Lat),
					fmt.Sprintf("%f", geo.Lon),
				).Inc()
			}
		}

		isp := q.ClientInfo.Whois.OrgName
        country := q.ClientInfo.Whois.Country

        if isp != "" {
	    queryCountByISP.WithLabelValues(isp, country).Inc()
        
	    }
	
	}

	logX("DEBUG",
		"Querylog: scanned=%d new=%d skipped=%d geoip=%d dedupHits=%d",
		scanned,
		processed,
		skipped,
		geoResolved,
                dedupHits,
	)
}

//cleanup memory
func cleanupQueryCache() {

	now := time.Now().Unix()

	queryMutex.Lock()

	for k, v := range querySeen {

		if now-v > queryTTL {
			delete(querySeen, k)
		}

	}

	queryMutex.Unlock()
}

func main() {

	dbPath := os.Getenv("GEOIP_DB")

	if dbPath == "" {
		dbPath = "GeoLite2-City.mmdb"
	}

	db, err := geoip2.Open(dbPath)

	if err != nil {
		logX("WARN", "GeoIP DB not found (%v) — geo metrics disabled", err)
	} else {
		geoDB = db
		logX("INFO", "GeoIP database loaded")
	}

	scrapeIntervalStr := os.Getenv("SCRAPE_INTERVAL")
	port := os.Getenv("EXPORTER_PORT")

	if port == "" {
		port = "9617"
	}

	interval, err := strconv.Atoi(scrapeIntervalStr)

	if err != nil || interval < 1 {
		interval = 15
	}
    
    logX("INFO", "Scrape interval set to %ds", interval)

	go func() {

        for {

            start := time.Now()

            updateStatsMetrics()
            updateStatusMetrics()
            updateQueryLogMetrics()

            cleanupQueryCache()

            duration := time.Since(start).Seconds()

            exporterScrapeDuration.Set(duration)
            exporterUp.Set(1)

			exporterQueryCacheSize.Set(float64(len(querySeen)))
            exporterGeoCacheSize.Set(float64(len(geoCache)))
            
            logX(
                "DEBUG",
                "Exporter caches: query_cache_entries=%d geo_cache_entries=%d dedup_hits_total=%d",
                len(querySeen),
                len(geoCache),
                dedupHits,
            )

			logX("DEBUG", "Scrape finished in %.3fs", duration)

            time.Sleep(time.Duration(interval) * time.Second)

        }

    }()

	http.Handle("/metrics", promhttp.Handler())

	logX("INFO", "Starting exporter at :%s ..", port)

	err = http.ListenAndServe(":"+port, nil)

	if err != nil {
		logX("ERROR", "Server failed: %v", err)
		os.Exit(1)
	}

}
