package dispatch

import (
	"context"
	"fmt"
	"time"

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

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

// Use a type alias so the file's handler signatures don't bleed the
// modulepkg import into their exposed type names.
type modulepkg_UpdateProvider = modulepkg.UpdateProvider

// registerUpdater stores the cancel func for the in-flight updater of
// an instance. Returns (slot, true) when registration succeeds, or
// (nil, false) when another updater is already running for the same
// instance.
//
// We refuse instead of preempting because preempting races the docker
// daemon's name cleanup: cancelling the prior context returns instantly,
// but the prior steamcmd sidecar (e.g. panel-<id>-steamcmd-1) takes a
// few seconds to actually exit and be removed. A new updater that
// charges in immediately hits a "name already in use" Docker error and,
// worse, leaves SteamCMD's staging directory mid-finalize (state 0x602)
// — files stranded under <install>/steamapps/downloading/<appid>/.
//
// Concrete failure mode that prompted this guard:
//   - panelctl create palworld-test → auto-triggers first update.
//   - Operator (impatiently) runs panelctl update palworld-test 13s later.
//   - Old behavior cancelled the auto-update mid-validate, then the new
//     run failed name-conflict, then SteamCMD looped on exit-8 / 0x602.
//
// The delete path uses cancelUpdater (different entry), so this guard
// doesn't block teardown.
func (d *Dispatcher) registerUpdater(instanceID string, cancel context.CancelFunc) (*updaterSlot, bool) {
	d.mu.Lock()
	defer d.mu.Unlock()
	if existing := d.updaters[instanceID]; existing != nil {
		return nil, false
	}
	slot := &updaterSlot{cancel: cancel}
	d.updaters[instanceID] = slot
	return slot, true
}

// unregisterUpdater clears the registration ONLY IF the current entry
// is still our slot — pointer compare guards against a later updater
// having taken over.
func (d *Dispatcher) unregisterUpdater(instanceID string, slot *updaterSlot) {
	d.mu.Lock()
	defer d.mu.Unlock()
	if d.updaters[instanceID] == slot {
		delete(d.updaters, instanceID)
	}
}

// cancelUpdater stops any in-flight updater for this instance and
// removes the registration. Called from the delete path so the sidecar
// gets torn down before we try to remove the volume.
func (d *Dispatcher) cancelUpdater(instanceID string) {
	d.mu.Lock()
	slot := d.updaters[instanceID]
	delete(d.updaters, instanceID)
	d.mu.Unlock()
	if slot != nil && slot.cancel != nil {
		slot.cancel()
	}
}

// Update handler. Controller sends ControllerEnvelope_Update; agent:
//
//  1. Looks up the instance + module manifest.
//  2. Picks the update provider by id (or defaults to the first one).
//  3. Emits a synchronous UpdateResult{accepted=true} so the panel can
//     un-gray the button and show "update started".
//  4. Spawns a goroutine that runs the provider, streaming each progress
//     line as a LogLine{stream="update"}. Final sentinel line
//     "update:ok" or "update:error:<msg>" tells the UI we're done.
//
// Progress is not correlation-routed because the UI subscribes to the
// normal event bus and picks up LogLines directly.

func (d *Dispatcher) handleUpdate(corrID string, req *panelv1.UpdateRequest) {
	d.handleUpdateWithHook(corrID, req, nil)
}

// handleUpdateWithHook is the internal form that optionally fires onComplete
// once the provider run finishes (success or failure). Used by the auto-
// update-on-create path to chain a Start after the first-time download and
// to clear UI state if the update errors out. The normal controller-driven
// Update flow passes nil so the hook stays opt-in.
func (d *Dispatcher) handleUpdateWithHook(corrID string, req *panelv1.UpdateRequest, onComplete func(err error)) {
	rec, err := d.lookupRecord(req.InstanceId)
	if err != nil {
		d.sendUpdateResult(corrID, false, err.Error(), "", "")
		return
	}
	manifest, ok := d.modules.Get(rec.ModuleID)
	if !ok {
		d.sendUpdateResult(corrID, false, "module not in registry", "", "")
		return
	}

	// Special pseudo-provider "_basemods": short-circuits to the third-
	// party base-mod installer (Allocs + PrismaCore) instead of running
	// the module's normal updater. Reuses the Update RPC plumbing so the
	// UI gets the same "updating" pulse + error surface as a real update.
	// Reasoning for the underscore prefix: real provider ids never start
	// with one (manifest schema convention), so collisions are impossible.
	if req.ProviderId == "_basemods" {
		d.sendUpdateResult(corrID, true, "", "_basemods", "basemods")
		d.log.Info("install-base-mods: kicked", "instance_id", req.InstanceId)
		go func() {
			ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
			defer cancel()
			err := d.installBaseMods(ctx, req.InstanceId, manifest, rec.DataPath)
			if err != nil {
				d.log.Error("install-base-mods: failed", "instance_id", req.InstanceId, "err", err)
			} else {
				d.log.Info("install-base-mods: done", "instance_id", req.InstanceId)
			}
			if onComplete != nil {
				onComplete(err)
			}
		}()
		return
	}

	// Pick provider: by id if supplied, else first in manifest.
	var spec *modulepkg_UpdateProvider
	for i := range manifest.UpdateProviders {
		p := &manifest.UpdateProviders[i]
		if req.ProviderId == "" || p.ID == req.ProviderId {
			spec = providerAdapter(p)
			break
		}
	}
	if spec == nil {
		d.sendUpdateResult(corrID, false, fmt.Sprintf("no provider %q on module %s", req.ProviderId, rec.ModuleID), "", "")
		return
	}

	prov, err := updater.Get(spec.Kind)
	if err != nil {
		d.sendUpdateResult(corrID, false, err.Error(), spec.ID, spec.Kind)
		return
	}

	// Reject if another updater is already running on this instance —
	// see registerUpdater for why we refuse instead of preempting.
	//
	// Timeout bumped 30 → 90 min (2026-05-04) — Steam CDN can rate-limit
	// anonymous-login pulls down to 1-3 MB/s, and a 17 GB game (7DTD,
	// ARK SA) at 5 MB/s = 56 min, easily over 30. Killing the sidecar
	// at 30 min leaves a partial install + a confused operator. 90 min
	// covers the realistic worst case with margin; truly hung steamcmd
	// still gets killed eventually so we don't burn an updater slot
	// forever.
	ctx, cancel := context.WithTimeout(context.Background(), 90*time.Minute)
	slot, ok := d.registerUpdater(req.InstanceId, cancel)
	if !ok {
		cancel()
		d.sendUpdateResult(corrID, false, "another update is already in progress for this instance", spec.ID, spec.Kind)
		d.log.Info("update rejected: already running", "instance_id", req.InstanceId, "provider_id", spec.ID)
		return
	}

	// Accept synchronously — run async.
	d.sendUpdateResult(corrID, true, "", spec.ID, spec.Kind)
	d.log.Info("update started", "instance_id", req.InstanceId, "provider_id", spec.ID, "kind", spec.Kind)

	// Surface the in-flight update as a server-authoritative status.
	// Without this, the panel's status pill stayed at whatever it was
	// before the update (often "stopped" or worse, "crashed" if the
	// previous run had hard-exited) — operators saw "crashed" while
	// SteamCMD was actually downloading several GB. Emitting UPDATING
	// here gives every dashboard viewer the same accurate "installing"
	// pulse, regardless of which client kicked off the update.
	d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_UPDATING, 0, "updating:"+spec.Kind)

	go func() {
		defer cancel()
		defer d.unregisterUpdater(req.InstanceId, slot)
		sink := func(line string) {
			d.sendEnv(&panelv1.AgentEnvelope{
				SentAt: timestamppb.Now(),
				Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
					InstanceId: rec.InstanceID,
					Stream:     "update",
					At:         timestamppb.Now(),
					Line:       line,
				}},
			})
		}
		sink(fmt.Sprintf("---- update started: provider=%s kind=%s ----", spec.ID, spec.Kind))
		uc := &updater.Context{
			InstanceID:     rec.InstanceID,
			Manifest:       manifest,
			ContainerID:    rec.ContainerID,
			BrowseableRoot: rec.BrowseableRoot,
			DataPath:       rec.DataPath,
			Runtime:        d.runtime,
			Log:            sink,
			// Steam login (if any) is forwarded by the controller for
			// modules that set `requires_steam_login`. Fields are empty
			// for anonymous-login apps — steamcmd.go falls back to that.
			SteamUsername: req.SteamUsername,
			SteamPassword: req.SteamPassword,
		}
		// ctx + cancel + slot were registered before the goroutine launched
		// (see registerUpdater above). The defer at the top of the goroutine
		// handles cleanup; the dispatcher's delete path can still abort us
		// via cancelUpdater.
		if err := prov.Update(ctx, uc, spec); err != nil {
			sink(fmt.Sprintf("update:error: %s", err.Error()))
			d.log.Warn("update failed", "instance_id", req.InstanceId, "err", err)
			// Clear the UPDATING pill — surface the failure as CRASHED with
			// a structured detail so operators see "install_failed: …" in
			// the UI rather than the pill being stuck on UPDATING forever.
			d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_CRASHED, -1, "install_failed: "+err.Error())
			if onComplete != nil {
				onComplete(err)
			}
			return
		}
		sink("update:ok")
		d.log.Info("update complete", "instance_id", req.InstanceId, "provider_id", spec.ID)
		// Update succeeded — drop UPDATING back to STOPPED. handleStart's
		// own state emission supersedes if an auto-start follows. Without
		// this, a manually-triggered update left the pill on UPDATING
		// indefinitely until the next start/stop cycle.
		d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_STOPPED, 0, "installed")
		if onComplete != nil {
			onComplete(nil)
		}
	}()
}

func (d *Dispatcher) sendUpdateResult(corrID string, accepted bool, errMsg, providerID, providerKind string) {
	d.sendEnv(&panelv1.AgentEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.AgentEnvelope_UpdateResult{
			UpdateResult: &panelv1.UpdateResult{
				Accepted:     accepted,
				Error:        errMsg,
				ProviderId:   providerID,
				ProviderKind: providerKind,
			},
		},
	})
}

// providerAdapter is a pass-through that pins us to the modulepkg type
// without needing to add another import alias at the top of this file.
func providerAdapter(p *modulepkg_UpdateProvider) *modulepkg_UpdateProvider { return p }
