package main

// Empyrion player history: subscribes to each running empyrion instance's
// bridge SSE feed, persists Event_Player_* packages into Postgres, and
// projects a `known_players` cache for the players-list view.
//
// Background goroutine started at controller boot. One subscription per
// empyrion instance with an entry in the panel's instance table; the
// goroutine reconnects on disconnect (bridge restarts, network blips).

import (
	"bufio"
	"bytes"
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"strings"
	"sync"
	"time"

	"github.com/dbledeez/panel/controller/internal/db"
)

type empyrionHistoryRecorder struct {
	db        *db.DB
	resolver  func(ctx context.Context, instanceID string) (*empyrionTarget, error)
	log       Logger
	mu        sync.Mutex
	subs      map[string]context.CancelFunc // instance_id → cancel
	stopped   bool
}

func newEmpyrionHistoryRecorder(database *db.DB, resolver func(ctx context.Context, instanceID string) (*empyrionTarget, error), log Logger) *empyrionHistoryRecorder {
	return &empyrionHistoryRecorder{db: database, resolver: resolver, log: log, subs: map[string]context.CancelFunc{}}
}

// Run starts the recorder loop. Periodically reconciles desired-vs-actual
// subscribers (one per empyrion instance), starting / stopping as needed.
func (r *empyrionHistoryRecorder) Run(ctx context.Context) {
	t := time.NewTicker(30 * time.Second)
	defer t.Stop()
	r.reconcile(ctx)
	for {
		select {
		case <-ctx.Done():
			r.shutdown()
			return
		case <-t.C:
			r.reconcile(ctx)
		}
	}
}

func (r *empyrionHistoryRecorder) shutdown() {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.stopped = true
	for _, cancel := range r.subs {
		cancel()
	}
	r.subs = nil
}

func (r *empyrionHistoryRecorder) reconcile(ctx context.Context) {
	r.mu.Lock()
	if r.stopped {
		r.mu.Unlock()
		return
	}
	r.mu.Unlock()

	rows, err := r.db.ListInstances(ctx, "")
	if err != nil {
		return
	}
	desired := map[string]bool{}
	for _, row := range rows {
		if row.ModuleID != "empyrion" {
			continue
		}
		desired[row.InstanceID] = true
	}

	r.mu.Lock()
	defer r.mu.Unlock()
	for id := range desired {
		if _, ok := r.subs[id]; ok {
			continue
		}
		subCtx, cancel := context.WithCancel(ctx)
		r.subs[id] = cancel
		go r.runSubscription(subCtx, id)
	}
	for id, cancel := range r.subs {
		if !desired[id] {
			cancel()
			delete(r.subs, id)
		}
	}
}

// runSubscription opens an SSE connection to the bridge and writes events
// into Postgres until the context is canceled. Reconnects with exponential
// backoff on transient failures. Also runs a parallel goroutine that polls
// Request_Dedi_Stats every 60s so the server-health sparkline has data
// even without unsolicited events.
func (r *empyrionHistoryRecorder) runSubscription(ctx context.Context, instanceID string) {
	go r.pollDediStats(ctx, instanceID)
	backoff := 5 * time.Second
	for {
		if ctx.Err() != nil {
			return
		}
		err := r.streamOnce(ctx, instanceID)
		if err != nil && !errors.Is(err, context.Canceled) {
			if r.log != nil {
				r.log.Info("empyrion history subscription closed", "instance_id", instanceID, "err", err)
			}
		}
		select {
		case <-ctx.Done():
			return
		case <-time.After(backoff):
		}
		if backoff < 60*time.Second {
			backoff *= 2
		}
	}
}

// pollDediStats fires POST /api/v1/dedi/stats every minute. Empyrion's mod
// API doesn't broadcast Event_Dedi_Stats unsolicited; it only responds to
// requests. We synthesize a heartbeat by polling and storing the response
// as an Event_Dedi_Stats row so the sparkline UI has data.
func (r *empyrionHistoryRecorder) pollDediStats(ctx context.Context, instanceID string) {
	t := time.NewTicker(60 * time.Second)
	defer t.Stop()
	for {
		// Fire immediately, then on every tick.
		r.pollDediStatsOnce(ctx, instanceID)
		select {
		case <-ctx.Done():
			return
		case <-t.C:
		}
	}
}

func (r *empyrionHistoryRecorder) pollDediStatsOnce(ctx context.Context, instanceID string) {
	tgt, err := r.resolver(ctx, instanceID)
	if err != nil {
		return
	}
	url := fmt.Sprintf("%s/api/v1/dedi/stats?host=%s&port=%d", strings.TrimRight(tgt.bridgeURL, "/"), tgt.host, tgt.port)
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
	if err != nil {
		return
	}
	if tgt.bridgeToken != "" {
		req.Header.Set("Authorization", "Bearer "+tgt.bridgeToken)
	}
	resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
	if err != nil {
		return
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		return
	}
	body, err := readAllBytes(resp.Body, 64*1024)
	if err != nil || len(body) == 0 || string(body) == "null" {
		return
	}
	_, _ = r.db.Pool().Exec(ctx, `
		INSERT INTO empyrion_events (instance_id, ts, cmd, raw)
		VALUES ($1, NOW(), 'Event_Dedi_Stats', $2::jsonb)`, instanceID, string(body))
}

func readAllBytes(rdr interface{ Read([]byte) (int, error) }, max int) ([]byte, error) {
	out := make([]byte, 0, 1024)
	buf := make([]byte, 4096)
	for len(out) < max {
		n, err := rdr.Read(buf)
		if n > 0 {
			out = append(out, buf[:n]...)
		}
		if err != nil {
			if err.Error() == "EOF" {
				return out, nil
			}
			return out, err
		}
	}
	return out, nil
}

func (r *empyrionHistoryRecorder) streamOnce(ctx context.Context, instanceID string) error {
	tgt, err := r.resolver(ctx, instanceID)
	if err != nil {
		return err
	}
	url := fmt.Sprintf("%s/api/v1/events?host=%s&port=%d", strings.TrimRight(tgt.bridgeURL, "/"), tgt.host, tgt.port)
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return err
	}
	if tgt.bridgeToken != "" {
		req.Header.Set("Authorization", "Bearer "+tgt.bridgeToken)
	}
	req.Header.Set("Accept", "text/event-stream")
	resp, err := (&http.Client{}).Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		return fmt.Errorf("bridge SSE returned %d", resp.StatusCode)
	}
	rd := bufio.NewReaderSize(resp.Body, 64*1024)
	var dataBuf bytes.Buffer
	for {
		line, err := rd.ReadBytes('\n')
		if err != nil {
			return err
		}
		ln := strings.TrimRight(string(line), "\r\n")
		if ln == "" {
			// dispatch
			if dataBuf.Len() > 0 {
				r.persistEvent(ctx, instanceID, dataBuf.Bytes())
				dataBuf.Reset()
			}
			continue
		}
		if strings.HasPrefix(ln, "data:") {
			dataBuf.WriteString(strings.TrimPrefix(strings.TrimPrefix(ln, "data:"), " "))
		}
		// :heartbeat / event:ready / etc. — ignored
	}
}

// persistEvent writes a single SSE-decoded JSON payload into the events
// table and (for player events) updates the known_players projection.
func (r *empyrionHistoryRecorder) persistEvent(ctx context.Context, instanceID string, data []byte) {
	var ev struct {
		Cmd   string          `json:"cmd"`
		CmdId int             `json:"cmdId"`
		Seq   int             `json:"seq"`
		Data  json.RawMessage `json:"data"`
		Type  string          `json:"type"`
	}
	if err := json.Unmarshal(data, &ev); err != nil {
		return
	}

	// Decode common payload shapes inline to extract structured columns.
	var (
		playerID  sql.NullInt64
		steamID   sql.NullString
		playerNm  sql.NullString
		playfield sql.NullString
		msg       sql.NullString
	)

	switch ev.Cmd {
	case "Event_Player_Connected", "Event_Player_Disconnected", "Event_Player_DisconnectedWaiting":
		var d struct {
			Id int `json:"id"`
		}
		_ = json.Unmarshal(ev.Data, &d)
		playerID = sql.NullInt64{Int64: int64(d.Id), Valid: d.Id != 0}
	case "Event_Player_ChangedPlayfield":
		var d struct {
			Id        int    `json:"id"`
			Playfield string `json:"playfield"`
		}
		_ = json.Unmarshal(ev.Data, &d)
		playerID = sql.NullInt64{Int64: int64(d.Id), Valid: d.Id != 0}
		playfield = sql.NullString{String: d.Playfield, Valid: d.Playfield != ""}
	case "Event_Player_Info":
		var d struct {
			EntityId   int    `json:"entityId"`
			SteamId    string `json:"steamId"`
			PlayerName string `json:"playerName"`
			Playfield  string `json:"playfield"`
		}
		_ = json.Unmarshal(ev.Data, &d)
		playerID = sql.NullInt64{Int64: int64(d.EntityId), Valid: d.EntityId != 0}
		steamID = sql.NullString{String: d.SteamId, Valid: d.SteamId != ""}
		playerNm = sql.NullString{String: d.PlayerName, Valid: d.PlayerName != ""}
		playfield = sql.NullString{String: d.Playfield, Valid: d.Playfield != ""}
	case "Event_ChatMessage", "Event_ChatMessageEx":
		var d struct {
			SenderEntityId int    `json:"senderEntityId"`
			Msg            string `json:"msg"`
			Type           int    `json:"type"`
		}
		_ = json.Unmarshal(ev.Data, &d)
		playerID = sql.NullInt64{Int64: int64(d.SenderEntityId), Valid: d.SenderEntityId != 0}
		msg = sql.NullString{String: d.Msg, Valid: d.Msg != ""}
	case "Event_PdaStateChange":
		var d struct {
			Id int `json:"id"`
		}
		_ = json.Unmarshal(ev.Data, &d)
		playerID = sql.NullInt64{Int64: int64(d.Id), Valid: d.Id != 0}
	case "Event_Dedi_Stats":
		// no structured columns to extract; raw JSON in `raw` is enough
		// for the dedi-stats sparkline endpoint to query.
	}

	_, err := r.db.Pool().Exec(ctx, `
		INSERT INTO empyrion_events (instance_id, ts, cmd, player_id, steam_id, player_name, playfield, msg, raw)
		VALUES ($1, NOW(), $2, $3, $4, $5, $6, $7, $8::jsonb)`,
		instanceID, ev.Cmd,
		nullInt(playerID), nullString(steamID), nullString(playerNm),
		nullString(playfield), nullString(msg), string(ev.Data))
	if err != nil {
		return
	}

	if playerID.Valid {
		// Upsert known_players. Login increments; logout/playfield/info just touches last_seen.
		isLogin := ev.Cmd == "Event_Player_Connected"
		_, _ = r.db.Pool().Exec(ctx, `
			INSERT INTO empyrion_known_players
			  (instance_id, player_id, steam_id, player_name, first_seen, last_seen, login_count, last_playfield)
			VALUES ($1, $2, $3, $4, NOW(), NOW(), CASE WHEN $5 THEN 1 ELSE 0 END, $6)
			ON CONFLICT (instance_id, player_id) DO UPDATE SET
			  last_seen = NOW(),
			  steam_id = CASE WHEN EXCLUDED.steam_id != '' THEN EXCLUDED.steam_id ELSE empyrion_known_players.steam_id END,
			  player_name = CASE WHEN EXCLUDED.player_name != '' THEN EXCLUDED.player_name ELSE empyrion_known_players.player_name END,
			  login_count = empyrion_known_players.login_count + CASE WHEN $5 THEN 1 ELSE 0 END,
			  last_playfield = CASE WHEN EXCLUDED.last_playfield != '' THEN EXCLUDED.last_playfield ELSE empyrion_known_players.last_playfield END`,
			instanceID, playerID.Int64,
			nullStringStr(steamID), nullStringStr(playerNm),
			isLogin, nullStringStr(playfield))
	}
}

func nullInt(n sql.NullInt64) any {
	if n.Valid {
		return n.Int64
	}
	return nil
}
func nullString(n sql.NullString) any {
	if n.Valid {
		return n.String
	}
	return nil
}
func nullStringStr(n sql.NullString) string {
	if n.Valid {
		return n.String
	}
	return ""
}
