package main

// DayZ Central Economy XML merger — operator-facing endpoints that let the
// panel drop a mod's types.xml/events.xml/etc snippet into a running
// instance's mission files, preview the diff, and commit with an automatic
// timestamped backup.
//
// Canonical merge semantics + supported file list live in pkg/dayzxml.

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

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

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

// dayzCEFile maps a dayzxml.FileKind to the container-relative path of the
// canonical file inside a standard Chernarus mission tree. Operators using
// non-default missions can still merge via the path-override form.
type dayzCEFile struct {
	Kind        dayzxml.FileKind `json:"kind"`
	Filename    string           `json:"filename"`     // "types.xml"
	MissionPath string           `json:"mission_path"` // "db/types.xml"  (relative to mission folder)
	Label       string           `json:"label"`        // "Loot items (types.xml)" — UI-friendly
}

// dayzKnownFiles enumerates the CE XML targets we support. Ordered for UI.
var dayzKnownFiles = []dayzCEFile{
	{Kind: dayzxml.KindTypes, Filename: "types.xml", MissionPath: "db/types.xml",
		Label: "Loot items (types.xml)"},
	{Kind: dayzxml.KindEvents, Filename: "events.xml", MissionPath: "db/events.xml",
		Label: "Dynamic events (events.xml)"},
	{Kind: dayzxml.KindSpawnableTypes, Filename: "cfgspawnabletypes.xml", MissionPath: "cfgspawnabletypes.xml",
		Label: "Spawnable attachments (cfgspawnabletypes.xml)"},
	{Kind: dayzxml.KindEventSpawns, Filename: "cfgeventspawns.xml", MissionPath: "cfgeventspawns.xml",
		Label: "Event spawn positions (cfgeventspawns.xml)"},
	{Kind: dayzxml.KindRandomPresets, Filename: "cfgrandompresets.xml", MissionPath: "cfgrandompresets.xml",
		Label: "Randomized presets (cfgrandompresets.xml)"},
	{Kind: dayzxml.KindMessages, Filename: "messages.xml", MissionPath: "messages.xml",
		Label: "Broadcast messages (messages.xml)"},
}

// handleDayzXmlFiles returns the list of supported CE XML files along with
// whether each one currently exists on the instance. The UI uses this to
// populate the target picker.
func (h *httpServer) handleDayzXmlFiles(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	agentID, err := h.lookupInstance(r, id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	conn := h.registry.get(agentID)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
		return
	}
	mission := r.URL.Query().Get("mission")
	if mission == "" {
		mission = "dayzOffline.chernarusplus"
	}
	// Probe each file via a cheap FsRead to see if it exists. The panel
	// usually mounts the game volume at /game; mpmissions lives there.
	type fileStatus struct {
		dayzCEFile
		Exists bool `json:"exists"`
		Size   int  `json:"size,omitempty"`
	}
	out := make([]fileStatus, 0, len(dayzKnownFiles))
	for _, f := range dayzKnownFiles {
		absPath := path.Join("/game/mpmissions", mission, f.MissionPath)
		res, _ := h.dayzXmlReadFile(r.Context(), conn, id, absPath)
		fs := fileStatus{dayzCEFile: f, Exists: res != nil && res.Error == ""}
		if fs.Exists {
			fs.Size = len(res.Content)
		}
		out = append(out, fs)
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"mission": mission,
		"files":   out,
	})
}

// dayzXmlPreviewReq is the payload for the preview + commit endpoints.
type dayzXmlPreviewReq struct {
	Mission string `json:"mission,omitempty"` // "dayzOffline.chernarusplus" (default)
	Path    string `json:"path,omitempty"`    // full mission-relative path; overrides kind→default mapping
	Kind    string `json:"kind,omitempty"`    // one of dayzxml.SupportedKinds() names; or infer from snippet
	Snippet string `json:"snippet"`           // the mod XML text
}

// handleDayzXmlPreview runs a dry-run merge and returns the Plan with
// counts + the first N entry names per bucket. No files are written.
func (h *httpServer) handleDayzXmlPreview(w http.ResponseWriter, r *http.Request) {
	h.dayzXmlDoMerge(w, r, true)
}

// handleDayzXmlCommit runs the merge for real: reads base, computes merged
// bytes, writes a timestamped backup, overwrites the target file.
func (h *httpServer) handleDayzXmlCommit(w http.ResponseWriter, r *http.Request) {
	h.dayzXmlDoMerge(w, r, false)
}

func (h *httpServer) dayzXmlDoMerge(w http.ResponseWriter, r *http.Request, dryRun bool) {
	id := r.PathValue("id")
	agentID, err := h.lookupInstance(r, id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	conn := h.registry.get(agentID)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
		return
	}
	// Accept either application/json body (with snippet as a string) or
	// raw application/xml (full body is the snippet, path/kind via query).
	var req dayzXmlPreviewReq
	ct := strings.ToLower(r.Header.Get("Content-Type"))
	if strings.HasPrefix(ct, "application/xml") || strings.HasPrefix(ct, "text/xml") {
		b, rerr := io.ReadAll(io.LimitReader(r.Body, 8*1024*1024))
		if rerr != nil {
			writeError(w, http.StatusBadRequest, "bad_body", rerr.Error())
			return
		}
		req.Snippet = string(b)
		req.Mission = r.URL.Query().Get("mission")
		req.Path = r.URL.Query().Get("path")
		req.Kind = r.URL.Query().Get("kind")
	} else {
		if err := json.NewDecoder(io.LimitReader(r.Body, 8*1024*1024)).Decode(&req); err != nil {
			writeError(w, http.StatusBadRequest, "bad_json", err.Error())
			return
		}
	}
	if strings.TrimSpace(req.Snippet) == "" {
		writeError(w, http.StatusBadRequest, "empty_snippet", "snippet is required")
		return
	}
	if req.Mission == "" {
		req.Mission = "dayzOffline.chernarusplus"
	}

	// Determine the kind: explicit > infer from snippet root.
	var kind dayzxml.FileKind
	if req.Kind != "" {
		kind = dayzxml.FileKind(req.Kind)
		if _, ok := dayzxml.SpecFor(kind); !ok {
			writeError(w, http.StatusBadRequest, "bad_kind", "unknown kind "+req.Kind)
			return
		}
	} else {
		k, derr := dayzxml.DetectKind([]byte(req.Snippet))
		if derr != nil {
			writeError(w, http.StatusBadRequest, "detect_kind", derr.Error())
			return
		}
		kind = k
	}

	// Resolve the target file path.
	targetPath := req.Path
	if targetPath == "" {
		for _, f := range dayzKnownFiles {
			if f.Kind == kind {
				targetPath = path.Join("/game/mpmissions", req.Mission, f.MissionPath)
				break
			}
		}
		if targetPath == "" {
			writeError(w, http.StatusBadRequest, "no_default_path", "no canonical path for kind "+string(kind)+"; supply `path`")
			return
		}
	}

	// Read the existing base.
	readRes, err := h.dayzXmlReadFile(r.Context(), conn, id, targetPath)
	if err != nil {
		writeError(w, http.StatusBadGateway, "fs_read", err.Error())
		return
	}
	if readRes.Error != "" {
		writeError(w, http.StatusNotFound, "base_missing", readRes.Error)
		return
	}

	// Merge.
	res, err := dayzxml.Merge(kind, readRes.Content, []byte(req.Snippet), dryRun)
	if err != nil {
		writeError(w, http.StatusBadRequest, "merge", err.Error())
		return
	}

	resp := map[string]any{
		"kind": string(kind),
		"path": targetPath,
		"plan": res.Plan,
	}
	if dryRun {
		writeJSON(w, http.StatusOK, resp)
		return
	}

	// Commit: back up base + write merged.
	ts := time.Now().UTC().Format("20060102-150405")
	backupPath := targetPath + ".bak." + ts
	if err := h.dayzXmlWriteFile(r.Context(), conn, id, backupPath, readRes.Content); err != nil {
		writeError(w, http.StatusBadGateway, "backup_failed", err.Error())
		return
	}
	if err := h.dayzXmlWriteFile(r.Context(), conn, id, targetPath, res.Bytes); err != nil {
		writeError(w, http.StatusBadGateway, "write_failed", err.Error())
		return
	}
	resp["committed"] = true
	resp["backup_path"] = backupPath
	resp["bytes_written"] = len(res.Bytes)
	writeJSON(w, http.StatusOK, resp)
}

// dayzXmlReadFile round-trips an FsRead through the agent gRPC bus. Returns
// the envelope result (including res.Error for missing files).
func (h *httpServer) dayzXmlReadFile(ctx context.Context, conn *agentConn, instanceID, absPath string) (*panelv1.FsReadResult, error) {
	corrID := newCorrelationID("dzxml_read")
	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_FsRead{
			FsRead: &panelv1.FsReadRequest{InstanceId: instanceID, Path: absPath},
		},
	}); err != nil {
		return nil, err
	}
	env, err := h.pending.Await(ctx, corrID, 15*time.Second)
	if err != nil {
		return nil, err
	}
	return env.GetFsReadResult(), nil
}

func (h *httpServer) dayzXmlWriteFile(ctx context.Context, conn *agentConn, instanceID, absPath string, content []byte) error {
	corrID := newCorrelationID("dzxml_write")
	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_FsWrite{
			FsWrite: &panelv1.FsWriteRequest{InstanceId: instanceID, Path: absPath, Content: content},
		},
	}); err != nil {
		return err
	}
	env, err := h.pending.Await(ctx, corrID, 30*time.Second)
	if err != nil {
		return err
	}
	res := env.GetFsWriteResult()
	if res == nil {
		return fmt.Errorf("agent returned no fs_write_result")
	}
	if res.Error != "" {
		return fmt.Errorf("%s", res.Error)
	}
	return nil
}
