package dispatch

import (
	"context"
	"fmt"
	"os"
	"path"
	"sync"
	"time"

	"google.golang.org/protobuf/types/known/timestamppb"

	"github.com/dbledeez/panel/agent/internal/updater"
	"github.com/dbledeez/panel/pkg/steamvdf"
	panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)

// Update-available check (WI-14). CHECK-ONLY: reads the installed
// buildid from the instance's Steam appmanifest ACF and the latest
// buildid for its branch via `steamcmd +app_info_print` — it never
// starts an update or touches the install.

// appInfoCacheTTL is how long a fetched branch map is reused before a
// fresh steamcmd run. app_info runs cost a sidecar spin-up (~10-30 s),
// so checks across many instances of the same game share one fetch.
const appInfoCacheTTL = 15 * time.Minute

type appInfoCacheEntry struct {
	branches  map[string]string
	fetchedAt time.Time
}

var (
	appInfoCacheMu sync.Mutex
	appInfoCache   = map[string]appInfoCacheEntry{} // key: app_id
	// appInfoFetchMu single-flights concurrent fetches per process; a
	// coarse lock is fine — checks are rare and operator-initiated.
	appInfoFetchMu sync.Mutex
)

func (d *Dispatcher) handleUpdateCheck(corrID string, req *panelv1.UpdateCheckRequest) {
	fail := func(msg string) {
		d.sendUpdateCheckResult(corrID, &panelv1.UpdateCheckResult{
			InstanceId: req.InstanceId, Error: msg,
		})
	}
	rec, err := d.lookupRecord(req.InstanceId)
	if err != nil {
		fail(err.Error())
		return
	}
	manifest, ok := d.modules.Get(rec.ModuleID)
	if !ok {
		fail("module not in registry")
		return
	}
	// Same provider selection as handleUpdate: by id when supplied, else
	// first in the manifest.
	var spec *modulepkg_UpdateProvider
	for i := range manifest.UpdateProviders {
		p := &manifest.UpdateProviders[i]
		if req.ProviderId == "" || p.ID == req.ProviderId {
			spec = p
			break
		}
	}
	if spec == nil {
		fail(fmt.Sprintf("no provider %q on module %s", req.ProviderId, rec.ModuleID))
		return
	}
	if spec.Kind != "steamcmd" {
		fail(fmt.Sprintf("update check supports steamcmd providers only (provider %q is %q)", spec.ID, spec.Kind))
		return
	}
	if spec.AppID == "" {
		fail("provider has no app_id")
		return
	}

	// The check spins a steamcmd sidecar (tens of seconds) — never block
	// the dispatch read loop.
	go func() {
		branch := spec.Beta
		branchKey := branch
		if branchKey == "" {
			branchKey = "public"
		}
		res := &panelv1.UpdateCheckResult{
			InstanceId: req.InstanceId,
			AppId:      spec.AppID,
			Branch:     branchKey,
		}

		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
		defer cancel()

		// Installed side: appmanifest ACF under the provider's install
		// root. Prefer betakey/buildid straight from the manifest file.
		if acf, rerr := d.readInstalledACF(ctx, rec, spec.InstallPath, spec.AppID); rerr != nil {
			res.Error = "read appmanifest: " + rerr.Error()
		} else if m, perr := steamvdf.ParseAppManifest(string(acf)); perr != nil {
			res.Error = "parse appmanifest: " + perr.Error()
		} else {
			res.InstalledBuildid = m.BuildID
			if m.BetaKey != "" {
				// Trust the on-disk truth over the manifest's declared
				// beta — this is exactly the "bounced between branches"
				// case the check exists to expose.
				res.Branch = m.BetaKey
				branchKey = m.BetaKey
			}
		}

		// Latest side: branch map via steamcmd app_info (15-min cache).
		branches, cached, ferr := d.latestBranches(ctx, rec.InstanceID, spec.AppID, req.Refresh)
		if ferr != nil {
			if res.Error != "" {
				res.Error += "; "
			}
			res.Error += "fetch latest: " + ferr.Error()
		} else {
			res.Cached = cached
			if bid, ok := branches[branchKey]; ok {
				res.LatestBuildid = bid
			} else if res.Error == "" {
				res.Error = fmt.Sprintf("branch %q not in Steam branches map (%d branches known)", branchKey, len(branches))
			}
		}

		res.UpdateAvailable = res.InstalledBuildid != "" && res.LatestBuildid != "" &&
			res.InstalledBuildid != res.LatestBuildid
		d.log.Info("update check",
			"instance_id", req.InstanceId, "app_id", spec.AppID, "branch", branchKey,
			"installed", res.InstalledBuildid, "latest", res.LatestBuildid,
			"update_available", res.UpdateAvailable, "cached", res.Cached, "err", res.Error)
		d.sendUpdateCheckResult(corrID, res)
	}()
}

// latestBranches returns the branch→buildid map for appID, from cache
// when fresh (unless refresh), otherwise via a steamcmd sidecar run.
func (d *Dispatcher) latestBranches(ctx context.Context, instanceID, appID string, refresh bool) (map[string]string, bool, error) {
	if !refresh {
		appInfoCacheMu.Lock()
		e, ok := appInfoCache[appID]
		appInfoCacheMu.Unlock()
		if ok && time.Since(e.fetchedAt) < appInfoCacheTTL {
			return e.branches, true, nil
		}
	}
	appInfoFetchMu.Lock()
	defer appInfoFetchMu.Unlock()
	// Re-check under the fetch lock — a concurrent check may have just
	// filled the cache while we waited.
	if !refresh {
		appInfoCacheMu.Lock()
		e, ok := appInfoCache[appID]
		appInfoCacheMu.Unlock()
		if ok && time.Since(e.fetchedAt) < appInfoCacheTTL {
			return e.branches, true, nil
		}
	}
	branches, err := updater.FetchAppInfoBranches(ctx, d.runtime, instanceID, appID, func(line string) {
		d.log.Debug("app_info", "instance_id", instanceID, "line", line)
	})
	if err != nil {
		return nil, false, err
	}
	appInfoCacheMu.Lock()
	appInfoCache[appID] = appInfoCacheEntry{branches: branches, fetchedAt: time.Now()}
	appInfoCacheMu.Unlock()
	return branches, false, nil
}

// readInstalledACF reads steamapps/appmanifest_<appID>.acf under the
// provider's install root, using the same container-first/host-fallback
// mechanics as the FsRead RPC (fs helper sidecar works on stopped
// instances too).
func (d *Dispatcher) readInstalledACF(ctx context.Context, rec *instanceRecord, installPath, appID string) ([]byte, error) {
	rel := path.Join("steamapps", fmt.Sprintf("appmanifest_%s.acf", appID))
	if d.useContainerOps(rec) {
		root := installPath
		if root == "" {
			root = rec.BrowseableRoot
		}
		abs, err := safeJoinAny(root, append(rootPaths(rec), root), path.Join(root, rel))
		if err != nil {
			return nil, err
		}
		targetID, err := d.getFsTargetContainerID(ctx, rec)
		if err != nil {
			return nil, err
		}
		return d.runtime.CopyFileFromContainer(ctx, targetID, abs)
	}
	if rec.DataPath == "" {
		return nil, fmt.Errorf("no file storage available")
	}
	abs, err := safeJoinHost(rec.DataPath, rel)
	if err != nil {
		return nil, err
	}
	return os.ReadFile(abs)
}

func (d *Dispatcher) sendUpdateCheckResult(corrID string, res *panelv1.UpdateCheckResult) {
	d.sendEnv(&panelv1.AgentEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload:       &panelv1.AgentEnvelope_UpdateCheckResult{UpdateCheckResult: res},
	})
}
