package metrics

import (
	"bytes"
	"context"
	"encoding/json"
	"math"
	"net/http"
	"os"
	"path/filepath"
	"runtime"
	"runtime/debug"
	"sync"
	"sync/atomic"
	"time"

	"github.com/Masterminds/squirrel"
	"github.com/google/uuid"
	"github.com/navidrome/navidrome/conf"
	"github.com/navidrome/navidrome/consts"
	"github.com/navidrome/navidrome/core/auth"
	"github.com/navidrome/navidrome/core/metrics/insights"
	"github.com/navidrome/navidrome/log"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/model/request"
	"github.com/navidrome/navidrome/plugins"
	"github.com/navidrome/navidrome/server/events"
	"github.com/navidrome/navidrome/utils/singleton"
)

type Insights interface {
	Run(ctx context.Context)
	LastRun(ctx context.Context) (timestamp time.Time, success bool)
}

var (
	insightsID string
)

type insightsCollector struct {
	ds         model.DataStore
	lastRun    atomic.Int64
	lastStatus atomic.Bool
}

func GetInstance(ds model.DataStore) Insights {
	return singleton.GetInstance(func() *insightsCollector {
		id, err := ds.Property(context.TODO()).Get(consts.InsightsIDKey)
		if err != nil {
			log.Trace("Could not get Insights ID from DB. Creating one", err)
			id = uuid.NewString()
			err = ds.Property(context.TODO()).Put(consts.InsightsIDKey, id)
			if err != nil {
				log.Trace("Could not save Insights ID to DB", err)
			}
		}
		insightsID = id
		return &insightsCollector{ds: ds}
	})
}

func (c *insightsCollector) Run(ctx context.Context) {
	for {
		// Refresh admin context on each iteration to handle cases where
		// admin user wasn't available on previous runs
		insightsCtx := auth.WithAdminUser(ctx, c.ds)
		u, _ := request.UserFrom(insightsCtx)
		if !u.IsAdmin {
			log.Trace(insightsCtx, "No admin user available, skipping insights collection")
		} else {
			c.sendInsights(insightsCtx)
		}
		select {
		case <-time.After(consts.InsightsUpdateInterval):
			continue
		case <-ctx.Done():
			return
		}
	}
}

func (c *insightsCollector) LastRun(context.Context) (timestamp time.Time, success bool) {
	t := c.lastRun.Load()
	return time.UnixMilli(t), c.lastStatus.Load()
}

func (c *insightsCollector) sendInsights(ctx context.Context) {
	count, err := c.ds.User(ctx).CountAll(model.QueryOptions{})
	if err != nil {
		log.Trace(ctx, "Could not check user count", err)
		return
	}
	if count == 0 {
		log.Trace(ctx, "No users found, skipping Insights data collection")
		return
	}
	hc := &http.Client{
		Timeout: consts.DefaultHttpClientTimeOut,
	}
	data := c.collect(ctx)
	if data == nil {
		return
	}
	body := bytes.NewReader(data)
	req, err := http.NewRequestWithContext(ctx, "POST", consts.InsightsEndpoint, body)
	if err != nil {
		log.Trace(ctx, "Could not create Insights request", err)
		return
	}
	req.Header.Set("Content-Type", "application/json")
	resp, err := hc.Do(req)
	if err != nil {
		log.Trace(ctx, "Could not send Insights data", err)
		return
	}
	log.Info(ctx, "Sent Insights data (for details see http://navidrome.org/docs/getting-started/insights", "data",
		string(data), "server", consts.InsightsEndpoint, "status", resp.Status)
	c.lastRun.Store(time.Now().UnixMilli())
	c.lastStatus.Store(resp.StatusCode < 300)
	resp.Body.Close()
}

func buildInfo() (map[string]string, string) {
	bInfo := map[string]string{}
	var version string
	if info, ok := debug.ReadBuildInfo(); ok {
		for _, setting := range info.Settings {
			if setting.Value == "" {
				continue
			}
			bInfo[setting.Key] = setting.Value
		}
		version = info.GoVersion
	}
	return bInfo, version
}

func getFSInfo(path string) *insights.FSInfo {
	var info insights.FSInfo

	// Normalize the path
	absPath, err := filepath.Abs(path)
	if err != nil {
		return nil
	}
	absPath = filepath.Clean(absPath)

	fsType, err := getFilesystemType(absPath)
	if err != nil {
		return nil
	}
	info.Type = fsType
	return &info
}

var staticData = sync.OnceValue(func() insights.Data {
	// Basic info
	data := insights.Data{
		InsightsID: insightsID,
		Version:    consts.Version,
	}

	// Build info
	data.Build.Settings, data.Build.GoVersion = buildInfo()
	data.OS.Containerized = consts.InContainer

	// Install info
	packageFilename := filepath.Join(conf.Server.DataFolder, ".package")
	packageFileData, err := os.ReadFile(packageFilename)
	if err == nil {
		data.OS.Package = string(packageFileData)
	}

	// OS info
	data.OS.Type = runtime.GOOS
	data.OS.Arch = runtime.GOARCH
	data.OS.NumCPU = runtime.NumCPU()
	data.OS.Version, data.OS.Distro = getOSVersion()

	// FS info
	data.FS.Music = getFSInfo(conf.Server.MusicFolder)
	data.FS.Data = getFSInfo(conf.Server.DataFolder)
	if conf.Server.CacheFolder != "" {
		data.FS.Cache = getFSInfo(conf.Server.CacheFolder)
	}
	if conf.Server.Backup.Path != "" {
		data.FS.Backup = getFSInfo(conf.Server.Backup.Path)
	}

	// Config info
	data.Config.LogLevel = conf.Server.LogLevel
	data.Config.LogFileConfigured = conf.Server.LogFile != ""
	data.Config.TLSConfigured = conf.Server.TLSCert != "" && conf.Server.TLSKey != ""
	data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL
	data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache
	data.Config.EnableCoverAnimation = conf.Server.EnableCoverAnimation
	data.Config.EnableNowPlaying = conf.Server.EnableNowPlaying
	data.Config.EnableDownloads = conf.Server.EnableDownloads
	data.Config.EnableSharing = conf.Server.EnableSharing
	data.Config.EnableStarRating = conf.Server.EnableStarRating
	data.Config.EnableLastFM = conf.Server.LastFM.Enabled && conf.Server.LastFM.ApiKey != "" && conf.Server.LastFM.Secret != ""
	data.Config.EnableSpotify = conf.Server.Spotify.ID != "" && conf.Server.Spotify.Secret != ""
	data.Config.EnableListenBrainz = conf.Server.ListenBrainz.Enabled
	data.Config.EnableDeezer = conf.Server.Deezer.Enabled
	data.Config.EnableMediaFileCoverArt = conf.Server.EnableMediaFileCoverArt
	data.Config.EnableJukebox = conf.Server.Jukebox.Enabled
	data.Config.EnablePrometheus = conf.Server.Prometheus.Enabled
	data.Config.TranscodingCacheSize = conf.Server.TranscodingCacheSize
	data.Config.ImageCacheSize = conf.Server.ImageCacheSize
	data.Config.SessionTimeout = uint64(math.Trunc(conf.Server.SessionTimeout.Seconds()))
	data.Config.SearchFullString = conf.Server.SearchFullString
	data.Config.RecentlyAddedByModTime = conf.Server.RecentlyAddedByModTime
	data.Config.PreferSortTags = conf.Server.PreferSortTags
	data.Config.BackupSchedule = conf.Server.Backup.Schedule
	data.Config.BackupCount = conf.Server.Backup.Count
	data.Config.DevActivityPanel = conf.Server.DevActivityPanel
	data.Config.ScannerEnabled = conf.Server.Scanner.Enabled
	data.Config.ScannerExtractor = conf.Server.Scanner.Extractor
	data.Config.ScanSchedule = conf.Server.Scanner.Schedule
	data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
	data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
	data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != ""
	data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != ""
	data.Config.HasCustomTags = len(conf.Server.Tags) > 0

	return data
})

func (c *insightsCollector) collect(ctx context.Context) []byte {
	data := staticData()
	data.Uptime = time.Since(consts.ServerStart).Milliseconds() / 1000

	// Library info
	var err error
	data.Library.Tracks, err = c.ds.MediaFile(ctx).CountAll()
	if err != nil {
		log.Trace(ctx, "Error reading tracks count", err)
	}
	data.Library.Albums, err = c.ds.Album(ctx).CountAll()
	if err != nil {
		log.Trace(ctx, "Error reading albums count", err)
	}
	data.Library.Artists, err = c.ds.Artist(ctx).CountAll()
	if err != nil {
		log.Trace(ctx, "Error reading artists count", err)
	}
	data.Library.Playlists, err = c.ds.Playlist(ctx).CountAll()
	if err != nil {
		log.Trace(ctx, "Error reading playlists count", err)
	}
	data.Library.Shares, err = c.ds.Share(ctx).CountAll()
	if err != nil {
		log.Trace(ctx, "Error reading shares count", err)
	}
	data.Library.Radios, err = c.ds.Radio(ctx).Count()
	if err != nil {
		log.Trace(ctx, "Error reading radios count", err)
	}
	data.Library.Libraries, err = c.ds.Library(ctx).CountAll()
	if err != nil {
		log.Trace(ctx, "Error reading libraries count", err)
	}
	data.Library.ActiveUsers, err = c.ds.User(ctx).CountAll(model.QueryOptions{
		Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)},
	})
	if err != nil {
		log.Trace(ctx, "Error reading active users count", err)
	}
	data.Library.FileSuffixes, err = c.ds.MediaFile(ctx).CountBySuffix()
	if err != nil {
		log.Trace(ctx, "Error reading file suffixes count", err)
	}

	// Check for smart playlists
	data.Config.HasSmartPlaylists, err = c.hasSmartPlaylists(ctx)
	if err != nil {
		log.Trace(ctx, "Error checking for smart playlists", err)
	}

	// Collect plugins if permitted and enabled
	if conf.Server.DevEnablePluginsInsights && conf.Server.Plugins.Enabled {
		data.Plugins = c.collectPlugins(ctx)
	}

	// Collect active players if permitted
	if conf.Server.DevEnablePlayerInsights {
		data.Library.ActivePlayers, err = c.ds.Player(ctx).CountByClient(model.QueryOptions{
			Filters: squirrel.Gt{"last_seen": time.Now().Add(-7 * 24 * time.Hour)},
		})
		if err != nil {
			log.Trace(ctx, "Error reading active players count", err)
		}
	}

	// Memory info
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	data.Mem.Alloc = m.Alloc
	data.Mem.TotalAlloc = m.TotalAlloc
	data.Mem.Sys = m.Sys
	data.Mem.NumGC = m.NumGC

	// Marshal to JSON
	resp, err := json.Marshal(data)
	if err != nil {
		log.Trace(ctx, "Could not marshal Insights data", err)
		return nil
	}
	return resp
}

// hasSmartPlaylists checks if there are any smart playlists (playlists with rules)
func (c *insightsCollector) hasSmartPlaylists(ctx context.Context) (bool, error) {
	count, err := c.ds.Playlist(ctx).CountAll(model.QueryOptions{
		Filters: squirrel.And{squirrel.NotEq{"rules": ""}, squirrel.NotEq{"rules": nil}},
	})
	return count > 0, err
}

// collectPlugins collects information about installed plugins
func (c *insightsCollector) collectPlugins(_ context.Context) map[string]insights.PluginInfo {
	// TODO Fix import/inject cycles
	manager := plugins.GetManager(c.ds, events.GetBroker(), nil)
	info := manager.GetPluginInfo()

	result := make(map[string]insights.PluginInfo, len(info))
	for name, p := range info {
		result[name] = insights.PluginInfo{
			Name:    p.Name,
			Version: p.Version,
		}
	}
	return result
}
