package dispatch

// Per-player POI discovery query against the empyrion server's
// global.db SQLite store. Spawns an alpine+sqlite sidecar with the
// instance's saves volume mounted read-only, runs a parameterized
// join query, returns the rows.
//
// Schema reference (DiscoveredPOIs / Entities / Playfields / SolarSystems
// / LoginLogoff) was reverse-engineered from a live empyrion install on
// 2026-04-30. Eleon's mod API does NOT expose per-player discovery —
// Request_GlobalStructure_List returns the server-wide aggregate that
// EAH shows. The DB has the finer per-player data the in-game map uses.

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"os/exec"
	"strings"
	"time"

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

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

// sqlite3's CLI doesn't support `?` parameter binding (that's a
// prepared-statement-only feature on the C API). We string-interpolate
// the SteamID64 — already validated as 17 digits server-side, but we
// also belt-and-suspenders re-validate at the shell layer (digits only).
//
// Two query modes:
//   "personal" — POIs the player personally first-walked-into
//   "faction"  — POIs anyone in the player's faction has discovered
//                (matches the in-game shared map)
const sqlPersonalTpl = `
SELECT
  COALESCE(ss.name, '') AS solar_system,
  COALESCE(pf.name, '') AS playfield,
  COALESCE(poi.name, '') AS poi_name,
  COALESCE(poi.etype, 0) AS poi_type,
  COALESCE(dp.gametime, 0) AS game_time,
  COALESCE(ll.playername, '') AS discoverer_name,
  COALESCE(ll.playerid, '') AS discoverer_steam_id,
  COALESCE(dp.facgroup, 0) AS facgroup,
  COALESCE(dp.facid, 0) AS facid
FROM DiscoveredPOIs dp
JOIN Entities poi ON dp.poiid = poi.entityid
LEFT JOIN Playfields pf ON dp.pfid = pf.pfid
LEFT JOIN SolarSystems ss ON pf.ssid = ss.ssid
LEFT JOIN LoginLogoff ll ON dp.entityid = ll.entityid
WHERE dp.entityid IN (
  SELECT DISTINCT entityid FROM LoginLogoff WHERE playerid='%SID%'
)
ORDER BY dp.gametime DESC
LIMIT 5000;
`

const sqlFactionTpl = `
SELECT
  COALESCE(ss.name, '') AS solar_system,
  COALESCE(pf.name, '') AS playfield,
  COALESCE(poi.name, '') AS poi_name,
  COALESCE(poi.etype, 0) AS poi_type,
  COALESCE(dp.gametime, 0) AS game_time,
  COALESCE(ll.playername, '') AS discoverer_name,
  COALESCE(ll.playerid, '') AS discoverer_steam_id,
  COALESCE(dp.facgroup, 0) AS facgroup,
  COALESCE(dp.facid, 0) AS facid
FROM DiscoveredPOIs dp
JOIN Entities poi ON dp.poiid = poi.entityid
LEFT JOIN Playfields pf ON dp.pfid = pf.pfid
LEFT JOIN SolarSystems ss ON pf.ssid = ss.ssid
LEFT JOIN LoginLogoff ll ON dp.entityid = ll.entityid
WHERE (dp.facgroup, dp.facid) IN (
  SELECT DISTINCT pe.facgroup, pe.facid
  FROM Entities pe
  WHERE pe.entityid IN (
    SELECT DISTINCT entityid FROM LoginLogoff WHERE playerid='%SID%'
  )
)
ORDER BY dp.gametime DESC
LIMIT 5000;
`

const sqlPlayerNameTpl = `
SELECT playername FROM LoginLogoff
WHERE playerid='%SID%'
ORDER BY loginticks DESC
LIMIT 1;
`

func (d *Dispatcher) handleEmpyrionDiscoveries(corrID string, req *panelv1.EmpyrionDiscoveriesRequest) {
	if req == nil || req.InstanceId == "" || req.SteamId == "" {
		d.sendEmpyrionDiscoveriesResult(corrID, &panelv1.EmpyrionDiscoveriesResult{
			Ok: false, Error: "instance_id and steam_id required",
		})
		return
	}
	go d.runEmpyrionDiscoveriesJob(corrID, req)
}

func (d *Dispatcher) runEmpyrionDiscoveriesJob(corrID string, req *panelv1.EmpyrionDiscoveriesRequest) {
	mode := strings.ToLower(strings.TrimSpace(req.Mode))
	if mode == "" || mode == "personal" {
		mode = "personal"
	} else if mode != "faction" {
		d.sendEmpyrionDiscoveriesResult(corrID, &panelv1.EmpyrionDiscoveriesResult{
			Ok: false, Error: "mode must be 'personal' or 'faction'", Mode: req.Mode, SteamId: req.SteamId,
		})
		return
	}

	dockerBin, err := exec.LookPath("docker")
	if err != nil {
		d.sendEmpyrionDiscoveriesResult(corrID, &panelv1.EmpyrionDiscoveriesResult{
			Ok: false, Error: "docker binary not found on agent host", Mode: mode, SteamId: req.SteamId,
		})
		return
	}

	// Saves volume name follows the panel convention: panel-<id>-saves.
	savesVol := "panel-" + req.InstanceId + "-saves"
	// Pre-flight: volume must exist (instance exists on this agent).
	if err := exec.Command(dockerBin, "volume", "inspect", savesVol).Run(); err != nil {
		d.sendEmpyrionDiscoveriesResult(corrID, &panelv1.EmpyrionDiscoveriesResult{
			Ok: false, Error: fmt.Sprintf("instance saves volume %q not found on this agent", savesVol),
			Mode: mode, SteamId: req.SteamId,
		})
		return
	}

	// Validate steam_id is digits-only — defense in depth, controller
	// already checks 17-digit format. Belt-and-suspenders against any
	// future caller that bypasses the controller's validation.
	for _, c := range req.SteamId {
		if c < '0' || c > '9' {
			d.sendEmpyrionDiscoveriesResult(corrID, &panelv1.EmpyrionDiscoveriesResult{
				Ok: false, Error: "steam_id must be digits only", Mode: mode, SteamId: req.SteamId,
			})
			return
		}
	}
	tpl := sqlPersonalTpl
	if mode == "faction" {
		tpl = sqlFactionTpl
	}
	q := strings.ReplaceAll(tpl, "%SID%", req.SteamId)
	nameQ := strings.ReplaceAll(sqlPlayerNameTpl, "%SID%", req.SteamId)

	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
	defer cancel()

	// alpine+sqlite sidecar. We don't need to discover the savegame
	// folder name dynamically — the entrypoint creates exactly one
	// (via dedicated.yaml.SaveDirectory which defaults to "DediGame";
	// the panel's empyrion module pins this). We pick `*/global.db`
	// at the shell layer so renames don't break the query.
	//
	// Note: -2 disables stdin which avoids the `command timed out`
	// path that empty stdin can hit on some Alpine builds. We feed the
	// SQL via the third positional arg.
	rowsScript := `set -e
apk add --no-cache sqlite >/dev/null 2>&1
DB=$(ls /saves/Saves/Games/*/global.db 2>/dev/null | head -1)
if [ -z "$DB" ]; then echo '{"err":"no global.db found in saves volume"}' >&2; exit 2; fi
NAME=$(sqlite3 "$DB" "$1" 2>/dev/null || echo "")
ROWS=$(sqlite3 -json "$DB" "$2" 2>&1)
if [ -z "$ROWS" ]; then ROWS='[]'; fi
NAME_JSON=$(printf '%s' "$NAME" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')
printf '{"player_name":"%s","rows":%s}\n' "$NAME_JSON" "$ROWS"
`

	out, err := exec.CommandContext(ctx, dockerBin,
		"run", "--rm",
		"-v", savesVol+":/saves:ro",
		"--entrypoint", "sh",
		"alpine:3.20", "-c", rowsScript, "_", nameQ, q,
	).Output()
	if err != nil {
		d.sendEmpyrionDiscoveriesResult(corrID, &panelv1.EmpyrionDiscoveriesResult{
			Ok: false, Error: "sqlite query failed: " + err.Error(),
			Mode: mode, SteamId: req.SteamId,
		})
		return
	}

	// Parse the wrapper json {"player_name": "...", "rows": [...]}.
	type wireRow struct {
		SolarSystem       string `json:"solar_system"`
		Playfield         string `json:"playfield"`
		PoiName           string `json:"poi_name"`
		PoiType           int32  `json:"poi_type"`
		GameTime          int64  `json:"game_time"`
		DiscovererName    string `json:"discoverer_name"`
		DiscovererSteamID string `json:"discoverer_steam_id"`
		Facgroup          int32  `json:"facgroup"`
		Facid             int32  `json:"facid"`
	}
	var wrap struct {
		PlayerName string    `json:"player_name"`
		Rows       []wireRow `json:"rows"`
	}
	// Strip leading whitespace; sqlite3 -json sometimes prepends a BOM-ish blank line.
	if err := json.Unmarshal([]byte(strings.TrimSpace(string(out))), &wrap); err != nil {
		// Tolerate "no rows" — sqlite3 -json prints `[]` (good) or empty
		// string when no DB (bad — caught above) or `null` for some
		// builds. Fall back to empty-result on parse failure.
		var rowsOnly []wireRow
		if err2 := json.Unmarshal([]byte(strings.TrimSpace(string(out))), &rowsOnly); err2 == nil {
			wrap.Rows = rowsOnly
		} else {
			d.sendEmpyrionDiscoveriesResult(corrID, &panelv1.EmpyrionDiscoveriesResult{
				Ok: false, Error: "parse sqlite output: " + err.Error(),
				Mode: mode, SteamId: req.SteamId,
			})
			return
		}
	}

	pois := make([]*panelv1.DiscoveredPOI, 0, len(wrap.Rows))
	for _, r := range wrap.Rows {
		pois = append(pois, &panelv1.DiscoveredPOI{
			SolarSystem:       r.SolarSystem,
			Playfield:         r.Playfield,
			PoiName:           r.PoiName,
			PoiType:           r.PoiType,
			GameTime:          r.GameTime,
			DiscovererName:    r.DiscovererName,
			DiscovererSteamId: r.DiscovererSteamID,
			FactionGroup:      r.Facgroup,
			FactionId:         r.Facid,
		})
	}
	d.sendEmpyrionDiscoveriesResult(corrID, &panelv1.EmpyrionDiscoveriesResult{
		Ok:         true,
		Pois:       pois,
		Mode:       mode,
		SteamId:    req.SteamId,
		PlayerName: strings.Trim(wrap.PlayerName, "\""),
	})
	_ = errors.New
}

func (d *Dispatcher) sendEmpyrionDiscoveriesResult(corrID string, res *panelv1.EmpyrionDiscoveriesResult) {
	d.sendEnv(&panelv1.AgentEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.AgentEnvelope_EmpyrionDiscoveriesResult{
			EmpyrionDiscoveriesResult: res,
		},
	})
}

// ============================================================================
// Player Summary — stats + sessions + inventory in one query
// ============================================================================

// playerSummaryScript is one big sh script run inside an alpine sidecar.
// It opens global.db, resolves the player's entityid via LoginLogoff,
// then runs four sqlite queries with -json output. The output is one
// JSON object with named keys for each query — we marshal-decode it
// directly into the proto result.
//
// The 4 queries:
//   1. summary  — Entities row + LoginLogoff name lookup + last position
//   2. stats    — PlayerStatistics row
//   3. sessions — recent LoginLogoff rows
//   4. inv      — most recent PlayerInventory snapshot + items
//
// We accept that some of these may return [] (player has no stats row
// yet, never logged in this savegame, etc.) — JSON parser handles it.
func playerSummaryScript() string {
	return `set -e
apk add --no-cache sqlite >/dev/null 2>&1
DB=$(ls /saves/Saves/Games/*/global.db 2>/dev/null | head -1)
if [ -z "$DB" ]; then echo '{"err":"no global.db found in saves volume"}' >&2; exit 2; fi
SID="$1"
LIMIT="${2:-50}"

# Resolve entityid for this Steam ID. Use the most recent LoginLogoff
# row to handle character resets that re-bind the same Steam ID to a
# new entityid.
EID=$(sqlite3 "$DB" "SELECT entityid FROM LoginLogoff WHERE playerid='${SID}' ORDER BY loginticks DESC LIMIT 1;")
if [ -z "$EID" ]; then
  printf '{"steam_id":"%s","entity_id":0,"player_name":"","stats":null,"sessions":[],"inventory":[]}\n' "$SID"
  exit 0
fi

NAME=$(sqlite3 "$DB" "SELECT playername FROM LoginLogoff WHERE entityid=${EID} ORDER BY loginticks DESC LIMIT 1;")
FG=$(sqlite3 "$DB" "SELECT facgroup FROM Entities WHERE entityid=${EID};")
FI=$(sqlite3 "$DB" "SELECT facid FROM Entities WHERE entityid=${EID};")

# Last position from PlayerData.
PD=$(sqlite3 -json "$DB" "
  SELECT
    COALESCE(pf.name, '') AS last_playfield,
    COALESCE(pd.posx, 0) AS last_x,
    COALESCE(pd.posy, 0) AS last_y,
    COALESCE(pd.posz, 0) AS last_z,
    COALESCE(pd.credits, 0) AS credits
  FROM PlayerData pd
  LEFT JOIN Playfields pf ON pd.pfid = pf.pfid
  WHERE pd.entityid=${EID};
")

STATS=$(sqlite3 -json "$DB" "
  SELECT
    COALESCE(killedenemies, 0) AS killed_enemies,
    COALESCE(killedallied, 0) AS killed_allied,
    COALESCE(killedanimals, 0) AS killed_animals,
    COALESCE(killeddrones, 0) AS killed_drones,
    COALESCE(killedplayers, 0) AS killed_players,
    COALESCE(killedalliedplayers, 0) AS killed_allied_players,
    COALESCE(died, 0) AS died,
    COALESCE(score, 0) AS score,
    COALESCE(blocksplaced, 0) AS blocks_placed,
    COALESCE(blocksdigged, 0) AS blocks_digged,
    COALESCE(walkedmeters, 0) AS walked_meters,
    COALESCE(jetpackmeters, 0) AS jetpack_meters,
    COALESCE(hvmeters, 0) AS hv_meters,
    COALESCE(svmeters, 0) AS sv_meters,
    COALESCE(cvmeters, 0) AS cv_meters,
    COALESCE(playtime, 0.0) AS playtime,
    COALESCE(travly, 0.0) AS light_years,
    COALESCE(travau, 0.0) AS astronomical_units
  FROM PlayerStatistics WHERE entityid=${EID};
")

SESS=$(sqlite3 -json "$DB" "
  SELECT
    COALESCE(loginticks, 0)  AS login_ticks,
    COALESCE(logoffticks, 0) AS logoff_ticks,
    COALESCE(playername, '') AS player_name,
    COALESCE(buildnr, 0)     AS build_nr,
    COALESCE(ip, '')         AS ip,
    COALESCE(os, '')         AS os,
    COALESCE(gfx, '')        AS gfx,
    COALESCE(cpu, '')        AS cpu
  FROM LoginLogoff WHERE entityid=${EID}
  ORDER BY loginticks DESC LIMIT ${LIMIT};
")

INV_PIID=$(sqlite3 "$DB" "SELECT piid FROM PlayerInventory WHERE entityid=${EID} ORDER BY gametime DESC LIMIT 1;")
INV_GT=0
INV='[]'
if [ -n "$INV_PIID" ]; then
  INV_GT=$(sqlite3 "$DB" "SELECT gametime FROM PlayerInventory WHERE piid=${INV_PIID};")
  INV=$(sqlite3 -json "$DB" "
    SELECT item AS item_id, count
    FROM PlayerInventoryItems WHERE piid=${INV_PIID};
  ")
  if [ -z "$INV" ]; then INV='[]'; fi
fi

if [ -z "$STATS" ]; then STATS='[]'; fi
if [ -z "$SESS" ]; then SESS='[]'; fi
if [ -z "$PD" ]; then PD='[]'; fi

printf '{"steam_id":"%s","player_name":%s,"entity_id":%s,"faction_group":%s,"faction_id":%s,"position":%s,"stats":%s,"sessions":%s,"inventory_gametime":%s,"inventory":%s}\n' \
  "$SID" \
  "$(printf '%%s' "$NAME" | sqlite3 ':memory:' 'select json_quote(?1);' 2>/dev/null || echo '""')" \
  "${EID:-0}" "${FG:-0}" "${FI:-0}" \
  "$PD" "$STATS" "$SESS" "${INV_GT:-0}" "$INV"
`
}

func (d *Dispatcher) handleEmpyrionPlayerSummary(corrID string, req *panelv1.EmpyrionPlayerSummaryRequest) {
	if req == nil || req.InstanceId == "" || req.SteamId == "" {
		d.sendEmpyrionPlayerSummaryResult(corrID, &panelv1.EmpyrionPlayerSummaryResult{
			Ok: false, Error: "instance_id and steam_id required",
		})
		return
	}
	go d.runEmpyrionPlayerSummaryJob(corrID, req)
}

func (d *Dispatcher) runEmpyrionPlayerSummaryJob(corrID string, req *panelv1.EmpyrionPlayerSummaryRequest) {
	dockerBin, err := exec.LookPath("docker")
	if err != nil {
		d.sendEmpyrionPlayerSummaryResult(corrID, &panelv1.EmpyrionPlayerSummaryResult{
			Ok: false, Error: "docker binary not found on agent host", SteamId: req.SteamId,
		})
		return
	}
	savesVol := "panel-" + req.InstanceId + "-saves"
	if err := exec.Command(dockerBin, "volume", "inspect", savesVol).Run(); err != nil {
		d.sendEmpyrionPlayerSummaryResult(corrID, &panelv1.EmpyrionPlayerSummaryResult{
			Ok: false, Error: fmt.Sprintf("instance saves volume %q not found on this agent", savesVol), SteamId: req.SteamId,
		})
		return
	}
	limit := req.MaxLoginRows
	if limit <= 0 || limit > 500 {
		limit = 50
	}

	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
	defer cancel()
	out, err := exec.CommandContext(ctx, dockerBin,
		"run", "--rm",
		"-v", savesVol+":/saves:ro",
		"--entrypoint", "sh",
		"alpine:3.20", "-c", playerSummaryScript(), "_",
		req.SteamId, fmt.Sprintf("%d", limit),
	).Output()
	if err != nil {
		d.sendEmpyrionPlayerSummaryResult(corrID, &panelv1.EmpyrionPlayerSummaryResult{
			Ok: false, Error: "sqlite query failed: " + err.Error(), SteamId: req.SteamId,
		})
		return
	}
	type wireStats struct {
		KilledEnemies       int64   `json:"killed_enemies"`
		KilledAllied        int64   `json:"killed_allied"`
		KilledAnimals       int64   `json:"killed_animals"`
		KilledDrones        int64   `json:"killed_drones"`
		KilledPlayers       int64   `json:"killed_players"`
		KilledAlliedPlayers int64   `json:"killed_allied_players"`
		Died                int64   `json:"died"`
		Score               int64   `json:"score"`
		BlocksPlaced        int64   `json:"blocks_placed"`
		BlocksDigged        int64   `json:"blocks_digged"`
		WalkedMeters        int64   `json:"walked_meters"`
		JetpackMeters       int64   `json:"jetpack_meters"`
		HvMeters            int64   `json:"hv_meters"`
		SvMeters            int64   `json:"sv_meters"`
		CvMeters            int64   `json:"cv_meters"`
		Playtime            float64 `json:"playtime"`
		LightYears          float64 `json:"light_years"`
		AstroUnits          float64 `json:"astronomical_units"`
	}
	type wireSession struct {
		LoginTicks  int64  `json:"login_ticks"`
		LogoffTicks int64  `json:"logoff_ticks"`
		PlayerName  string `json:"player_name"`
		BuildNr     int64  `json:"build_nr"`
		IP          string `json:"ip"`
		OS          string `json:"os"`
		GFX         string `json:"gfx"`
		CPU         string `json:"cpu"`
	}
	type wireInvItem struct {
		ItemID int32 `json:"item_id"`
		Count  int32 `json:"count"`
	}
	type wirePos struct {
		LastPlayfield string  `json:"last_playfield"`
		LastX         float64 `json:"last_x"`
		LastY         float64 `json:"last_y"`
		LastZ         float64 `json:"last_z"`
		Credits       int64   `json:"credits"`
	}
	type wire struct {
		SteamID           string         `json:"steam_id"`
		PlayerName        string         `json:"player_name"`
		EntityID          int64          `json:"entity_id"`
		FactionGroup      int32          `json:"faction_group"`
		FactionID         int32          `json:"faction_id"`
		Position          []wirePos      `json:"position"`
		Stats             []wireStats    `json:"stats"`
		Sessions          []wireSession  `json:"sessions"`
		InventoryGametime int64          `json:"inventory_gametime"`
		Inventory         []wireInvItem  `json:"inventory"`
	}
	var w wire
	if err := json.Unmarshal([]byte(strings.TrimSpace(string(out))), &w); err != nil {
		d.sendEmpyrionPlayerSummaryResult(corrID, &panelv1.EmpyrionPlayerSummaryResult{
			Ok: false, Error: "parse sqlite output: " + err.Error(), SteamId: req.SteamId,
		})
		return
	}
	res := &panelv1.EmpyrionPlayerSummaryResult{
		Ok:                true,
		SteamId:           w.SteamID,
		PlayerName:        strings.Trim(w.PlayerName, "\""),
		EntityId:          w.EntityID,
		FactionGroup:      w.FactionGroup,
		FactionId:         w.FactionID,
		InventoryGametime: w.InventoryGametime,
	}
	if len(w.Position) > 0 {
		res.LastPlayfield = w.Position[0].LastPlayfield
		res.LastX = w.Position[0].LastX
		res.LastY = w.Position[0].LastY
		res.LastZ = w.Position[0].LastZ
		res.Credits = w.Position[0].Credits
	}
	if len(w.Stats) > 0 {
		s := w.Stats[0]
		res.Stats = &panelv1.PlayerStatsRow{
			KilledEnemies: s.KilledEnemies, KilledAllied: s.KilledAllied,
			KilledAnimals: s.KilledAnimals, KilledDrones: s.KilledDrones,
			KilledPlayers: s.KilledPlayers, KilledAlliedPlayers: s.KilledAlliedPlayers,
			Died: s.Died, Score: s.Score,
			BlocksPlaced: s.BlocksPlaced, BlocksDigged: s.BlocksDigged,
			WalkedMeters: s.WalkedMeters, JetpackMeters: s.JetpackMeters,
			HvMeters: s.HvMeters, SvMeters: s.SvMeters, CvMeters: s.CvMeters,
			Playtime: s.Playtime, LightYears: s.LightYears, AstronomicalUnits: s.AstroUnits,
		}
	}
	for _, s := range w.Sessions {
		res.Sessions = append(res.Sessions, &panelv1.PlayerLoginRow{
			LoginTicks: s.LoginTicks, LogoffTicks: s.LogoffTicks,
			PlayerName: s.PlayerName, BuildNr: s.BuildNr,
			Ip: s.IP, Os: s.OS, Gfx: s.GFX, Cpu: s.CPU,
		})
	}
	for _, it := range w.Inventory {
		res.Inventory = append(res.Inventory, &panelv1.PlayerInventoryItem{
			ItemId: it.ItemID, Count: it.Count,
		})
	}
	d.sendEmpyrionPlayerSummaryResult(corrID, res)
}

func (d *Dispatcher) sendEmpyrionPlayerSummaryResult(corrID string, res *panelv1.EmpyrionPlayerSummaryResult) {
	d.sendEnv(&panelv1.AgentEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.AgentEnvelope_EmpyrionPlayerSummaryResult{
			EmpyrionPlayerSummaryResult: res,
		},
	})
}
