package main

// One-click mod installer.
//
// The scaffold is generic: installableMods declares each known mod
// (id, source, target path inside container, supported games) and the
// install pipeline streams the bundle to the agent via FsWrite.
//
// The catalog + embedded bundle live in per-build-tag files:
//   - installmod_default.go  (//go:build !refugebot) — empty catalog,
//     no embedded bytes. This is what a public checkout builds.
//   - installmod_refugebot.go (//go:build refugebot) — embeds the
//     proprietary RefugeBot 7DTD plugin from embedded_mods/ (present
//     only on the maintainer's machine; the dir is gitignored).
// Adding a new mod or wiring up "fetch from a hosted bundle URL"
// later means dropping a new entry — the install pipeline below
// doesn't need to change.

import (
	"context"
	"encoding/json"
	"fmt"
	"io/fs"
	"net/http"
	"path"
	"strings"
	"time"

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

	panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)

// InstallableMod describes a mod the panel knows how to install.
//
// Source is "embedded:<dir>" today — relative to embedded_mods/ in
// this package. Future variants:
//   - "github_release:<repo>:<asset>" → fetch latest release asset
//   - "url:<https://…/bundle.tar.gz>"  → fetch from a hosted URL
// Already plumbed in the install path so adding either is a Source-
// parser change, not a new endpoint.
type InstallableMod struct {
	ID            string   // stable id used by the API + UI
	DisplayName   string
	Description   string
	SupportedGame []string // module ids this mod targets
	Source        string   // "embedded:refugebot" today
	// TargetPath is where the mod folder lives INSIDE the container.
	// We copy the whole bundle into TargetPath/<mod folder>.
	TargetPath    string
	// TargetSubdir is the directory name we create at TargetPath.
	// E.g. for 7DTD: TargetPath=/game/Mods, TargetSubdir=RefugeBot →
	//   /game/Mods/RefugeBot/<files>
	TargetSubdir  string
	// RestartAfter, when true, the install endpoint advises the UI
	// to restart the server so the mod actually loads (DLL mods need
	// a process restart). Doesn't auto-restart; just hints.
	RestartAfter  bool
}

// handleCapabilities — GET /api/capabilities
// Lets the UI discover which optional (build-tag-gated) features this
// controller binary was compiled with, so it can hide dead UI.
func (h *httpServer) handleCapabilities(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, map[string]any{"refugebot": capRefugeBot})
}

// handleListInstallableMods — GET /api/mods/installable?module=7dtd
// Lets the UI render available mods filtered to the current game.
func (h *httpServer) handleListInstallableMods(w http.ResponseWriter, r *http.Request) {
	moduleID := r.URL.Query().Get("module")
	out := make([]map[string]any, 0, len(installableMods))
	for _, m := range installableMods {
		if moduleID != "" && !contains(m.SupportedGame, moduleID) {
			continue
		}
		out = append(out, map[string]any{
			"id":            m.ID,
			"display_name":  m.DisplayName,
			"description":   m.Description,
			"supported":     m.SupportedGame,
			"target_path":   path.Join(m.TargetPath, m.TargetSubdir),
			"restart_after": m.RestartAfter,
		})
	}
	writeJSON(w, http.StatusOK, map[string]any{"mods": out})
}

// handleInstallMod — POST /api/instances/{id}/install-mod {"mod_id":"refugebot"}
// Streams every file in the mod's bundle to the agent via FsWrite,
// rooted at TargetPath/TargetSubdir/. Returns the file count + total
// bytes written so the UI can show "installed N files (M MB)".
func (h *httpServer) handleInstallMod(w http.ResponseWriter, r *http.Request) {
	instanceID := r.PathValue("id")
	if instanceID == "" {
		writeError(w, http.StatusBadRequest, "missing_id", "instance id required")
		return
	}
	var req struct {
		ModID string `json:"mod_id"`
	}
	if err := decodeJSON(r, &req); err != nil {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	mod, ok := findMod(req.ModID)
	if !ok {
		writeError(w, http.StatusNotFound, "unknown_mod", "no such mod in catalog")
		return
	}
	// Confirm the instance exists + the module matches.
	rows, err := h.db.ListInstances(r.Context(), "")
	if err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	var agentID, moduleID string
	for _, row := range rows {
		if row.InstanceID == instanceID {
			agentID = row.AgentID
			moduleID = row.ModuleID
			break
		}
	}
	if agentID == "" {
		writeError(w, http.StatusNotFound, "not_found", "instance not in db")
		return
	}
	if !contains(mod.SupportedGame, moduleID) {
		writeError(w, http.StatusBadRequest, "wrong_module",
			fmt.Sprintf("mod %q doesn't support module %q", mod.ID, moduleID))
		return
	}
	conn := h.registry.get(agentID)
	if conn == nil {
		writeError(w, http.StatusServiceUnavailable, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
		return
	}

	// Walk the embedded bundle and FsWrite each file to the container.
	// The agent's FsWrite handler creates parent dirs as needed.
	srcDir, ok := strings.CutPrefix(mod.Source, "embedded:")
	if !ok {
		writeError(w, http.StatusInternalServerError, "bad_source", "only embedded sources supported today")
		return
	}
	rootDir := path.Join("embedded_mods", srcDir)

	type writeOK struct {
		Path string
		Size int64
	}
	var wrote []writeOK
	var totalBytes int64

	walkErr := fs.WalkDir(embeddedModsFS, rootDir, func(p string, d fs.DirEntry, werr error) error {
		if werr != nil {
			return werr
		}
		if d.IsDir() {
			return nil
		}
		// Compute container-relative path: strip rootDir prefix, prepend
		// TargetPath/TargetSubdir/. Path uses forward-slashes — the agent's
		// FsWrite expects POSIX paths.
		rel := strings.TrimPrefix(p, rootDir+"/")
		dst := path.Join(mod.TargetPath, mod.TargetSubdir, rel)

		content, rerr := embeddedModsFS.ReadFile(p)
		if rerr != nil {
			return fmt.Errorf("read embedded %s: %w", p, rerr)
		}

		corrID := newCorrelationID("install-mod")
		if err := conn.Send(&panelv1.ControllerEnvelope{
			CorrelationId: corrID,
			SentAt:        timestamppb.Now(),
			Payload: &panelv1.ControllerEnvelope_FsWrite{
				FsWrite: &panelv1.FsWriteRequest{
					InstanceId: instanceID,
					Path:       dst,
					Content:    content,
				},
			},
		}); err != nil {
			return fmt.Errorf("send fs_write %s: %w", dst, err)
		}
		ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
		env, err := h.pending.Await(ctx, corrID, 60*time.Second)
		cancel()
		if err != nil {
			return fmt.Errorf("await fs_write %s: %w", dst, err)
		}
		res := env.GetFsWriteResult()
		if res == nil {
			return fmt.Errorf("agent missing fs_write_result for %s", dst)
		}
		if res.Error != "" {
			return fmt.Errorf("fs_write %s: %s", dst, res.Error)
		}
		wrote = append(wrote, writeOK{Path: dst, Size: int64(len(content))})
		totalBytes += int64(len(content))
		return nil
	})
	if walkErr != nil {
		writeError(w, http.StatusBadGateway, "install_failed", walkErr.Error())
		return
	}

	// The RefugeBot Agent goes through first-boot setup at TCP :8765 — no
	// pre-seed needed. Operator opens http://<host>:8765 after the restart,
	// pastes the AgentKey from app.refugegaming.org, and the agent saves its
	// config to two locations (mod folder + <server-root>/RefugeBotData/
	// refugebot-agent.json) before connecting to the SaaS.

	h.log.Info("installed mod", "instance_id", instanceID, "mod_id", mod.ID, "files", len(wrote), "bytes", totalBytes)
	writeJSON(w, http.StatusOK, map[string]any{
		"mod_id":        mod.ID,
		"files":         len(wrote),
		"bytes":         totalBytes,
		"target":        path.Join(mod.TargetPath, mod.TargetSubdir),
		"restart_after": mod.RestartAfter,
	})
}

func findMod(id string) (InstallableMod, bool) {
	for _, m := range installableMods {
		if m.ID == id {
			return m, true
		}
	}
	return InstallableMod{}, false
}

func contains(s []string, v string) bool {
	for _, x := range s {
		if x == v {
			return true
		}
	}
	return false
}

// decodeJSON is the small boilerplate the rest of the handlers use
// inline. Pulled out here for readability.
func decodeJSON(r *http.Request, out any) error {
	return json.NewDecoder(r.Body).Decode(out)
}
