package runtime

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"time"

	"galaxy/backend/internal/postgres/jet/backend/model"
	"galaxy/backend/internal/postgres/jet/backend/table"

	"github.com/go-jet/jet/v2/postgres"
	"github.com/go-jet/jet/v2/qrm"
	"github.com/google/uuid"
)

// engineVersionsPK is the constraint name surfaced when a duplicate
// `version` is inserted. Postgres synthesises `<table>_pkey` for the
// primary-key constraint, matching the migration in
// `backend/internal/postgres/migrations/00001_init.sql:407`.
const engineVersionsPK = "engine_versions_pkey"

// runtimeRecordsPK is the constraint name surfaced when a duplicate
// `runtime_records.game_id` insert hits the primary key.
const runtimeRecordsPK = "runtime_records_pkey"

// playerMappingsRaceUnique mirrors
// `player_mappings_game_race_uidx`, the partial UNIQUE that enforces
// the one-race-per-game invariant.
const playerMappingsRaceUnique = "player_mappings_game_race_uidx"

// Store is the Postgres-backed query surface for the runtime package.
// All queries are built through go-jet against the generated table
// bindings under `backend/internal/postgres/jet/backend/table`.
type Store struct {
	db *sql.DB
}

// NewStore constructs a Store wrapping db.
func NewStore(db *sql.DB) *Store { return &Store{db: db} }

// engineVersionColumns is the canonical projection used by every
// engine-version read path.
func engineVersionColumns() postgres.ColumnList {
	v := table.EngineVersions
	return postgres.ColumnList{v.Version, v.ImageRef, v.Enabled, v.CreatedAt, v.UpdatedAt}
}

// runtimeRecordColumns is the canonical projection used by every
// runtime-record read path.
func runtimeRecordColumns() postgres.ColumnList {
	r := table.RuntimeRecords
	return postgres.ColumnList{
		r.GameID, r.Status, r.CurrentContainerID, r.CurrentImageRef,
		r.CurrentEngineVersion, r.EngineEndpoint, r.StatePath, r.DockerNetwork,
		r.TurnSchedule, r.CurrentTurn, r.NextGenerationAt, r.SkipNextTick,
		r.Paused, r.PausedAt, r.EngineHealth,
		r.CreatedAt, r.UpdatedAt, r.StartedAt, r.StoppedAt, r.FinishedAt,
		r.RemovedAt, r.LastObservedAt,
	}
}

// operationLogColumns is the canonical projection used by every read
// of `backend.runtime_operation_log`.
func operationLogColumns() postgres.ColumnList {
	o := table.RuntimeOperationLog
	return postgres.ColumnList{
		o.OperationID, o.GameID, o.Op, o.Source, o.Status, o.ImageRef,
		o.ContainerID, o.ErrorCode, o.ErrorMessage, o.StartedAt, o.FinishedAt,
	}
}

// =====================================================================
// Engine version registry
// =====================================================================

// ListEngineVersions returns every engine_versions row ordered by
// created_at DESC.
func (s *Store) ListEngineVersions(ctx context.Context) ([]EngineVersion, error) {
	v := table.EngineVersions
	stmt := postgres.SELECT(engineVersionColumns()).
		FROM(v).
		ORDER_BY(v.CreatedAt.DESC(), v.Version.DESC())
	var rows []model.EngineVersions
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("runtime store: list engine versions: %w", err)
	}
	out := make([]EngineVersion, 0, len(rows))
	for _, row := range rows {
		out = append(out, modelToEngineVersion(row))
	}
	return out, nil
}

// GetEngineVersion returns the row for version. Returns ErrNotFound
// when no row matches.
func (s *Store) GetEngineVersion(ctx context.Context, version string) (EngineVersion, error) {
	v := table.EngineVersions
	stmt := postgres.SELECT(engineVersionColumns()).
		FROM(v).
		WHERE(v.Version.EQ(postgres.String(version))).
		LIMIT(1)
	var row model.EngineVersions
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return EngineVersion{}, ErrNotFound
		}
		return EngineVersion{}, fmt.Errorf("runtime store: load engine version %q: %w", version, err)
	}
	return modelToEngineVersion(row), nil
}

// InsertEngineVersion persists a fresh engine version row. Returns
// ErrEngineVersionTaken when the primary key collides.
func (s *Store) InsertEngineVersion(ctx context.Context, version, imageRef string, enabled bool, now time.Time) (EngineVersion, error) {
	v := table.EngineVersions
	stmt := v.INSERT(v.Version, v.ImageRef, v.Enabled, v.CreatedAt, v.UpdatedAt).
		VALUES(version, imageRef, enabled, now, now).
		RETURNING(engineVersionColumns())
	var row model.EngineVersions
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if isUniqueViolation(err, engineVersionsPK) {
			return EngineVersion{}, ErrEngineVersionTaken
		}
		return EngineVersion{}, fmt.Errorf("runtime store: insert engine version %q: %w", version, err)
	}
	return modelToEngineVersion(row), nil
}

// engineVersionUpdate carries the parameters for UpdateEngineVersion.
// Nil pointers leave the corresponding column alone.
type engineVersionUpdate struct {
	ImageRef *string
	Enabled *bool
}

// UpdateEngineVersion patches the supplied columns and bumps
// updated_at. Returns ErrNotFound when no row matches.
func (s *Store) UpdateEngineVersion(ctx context.Context, version string, patch engineVersionUpdate, now time.Time) (EngineVersion, error) {
	v := table.EngineVersions
	rest := []any{}
	if patch.ImageRef != nil {
		rest = append(rest, v.ImageRef.SET(postgres.String(*patch.ImageRef)))
	}
	if patch.Enabled != nil {
		rest = append(rest, v.Enabled.SET(postgres.Bool(*patch.Enabled)))
	}
	stmt := v.UPDATE().
		SET(v.UpdatedAt.SET(postgres.TimestampzT(now)), rest...).
		WHERE(v.Version.EQ(postgres.String(version))).
		RETURNING(engineVersionColumns())

	var row model.EngineVersions
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return EngineVersion{}, ErrNotFound
		}
		return EngineVersion{}, fmt.Errorf("runtime store: update engine version %q: %w", version, err)
	}
	return modelToEngineVersion(row), nil
}

// =====================================================================
// Runtime records
// =====================================================================

// runtimeRecordInsert carries the parameters for InsertRuntimeRecord.
type runtimeRecordInsert struct {
	GameID uuid.UUID
	Status string
	CurrentContainerID string
	CurrentImageRef string
	CurrentEngineVersion string
	EngineEndpoint string
	StatePath string
	DockerNetwork string
	TurnSchedule string
	StartedAt *time.Time
}

// InsertRuntimeRecord creates a fresh row.
func (s *Store) InsertRuntimeRecord(ctx context.Context, in runtimeRecordInsert) (RuntimeRecord, error) {
	r := table.RuntimeRecords
	stmt := r.INSERT(
		r.GameID, r.Status, r.CurrentContainerID, r.CurrentImageRef,
		r.CurrentEngineVersion, r.EngineEndpoint, r.StatePath,
		r.DockerNetwork, r.TurnSchedule, r.StartedAt,
	).VALUES(
		in.GameID, in.Status,
		nullableString(in.CurrentContainerID),
		nullableString(in.CurrentImageRef),
		nullableString(in.CurrentEngineVersion),
		in.EngineEndpoint,
		nullableString(in.StatePath),
		nullableString(in.DockerNetwork),
		in.TurnSchedule,
		nullableTime(in.StartedAt),
	).RETURNING(runtimeRecordColumns())

	var row model.RuntimeRecords
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if isUniqueViolation(err, runtimeRecordsPK) {
			return RuntimeRecord{}, ErrConflict
		}
		return RuntimeRecord{}, fmt.Errorf("runtime store: insert runtime_record %s: %w", in.GameID, err)
	}
	return modelToRuntimeRecord(row), nil
}

// LoadRuntimeRecord returns the row for gameID. Returns ErrNotFound
// when no row matches.
func (s *Store) LoadRuntimeRecord(ctx context.Context, gameID uuid.UUID) (RuntimeRecord, error) {
	r := table.RuntimeRecords
	stmt := postgres.SELECT(runtimeRecordColumns()).
		FROM(r).
		WHERE(r.GameID.EQ(postgres.UUID(gameID))).
		LIMIT(1)
	var row model.RuntimeRecords
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return RuntimeRecord{}, ErrNotFound
		}
		return RuntimeRecord{}, fmt.Errorf("runtime store: load runtime_record %s: %w", gameID, err)
	}
	return modelToRuntimeRecord(row), nil
}

// ListAllRuntimeRecords returns every row, used by Cache.Warm.
func (s *Store) ListAllRuntimeRecords(ctx context.Context) ([]RuntimeRecord, error) {
	stmt := postgres.SELECT(runtimeRecordColumns()).FROM(table.RuntimeRecords)
	var rows []model.RuntimeRecords
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("runtime store: list runtime_records: %w", err)
	}
	out := make([]RuntimeRecord, 0, len(rows))
	for _, row := range rows {
		out = append(out, modelToRuntimeRecord(row))
	}
	return out, nil
}

// runtimeRecordUpdate carries the parameters for UpdateRuntimeRecord.
// Pointer fields default to "leave alone" when nil.
type runtimeRecordUpdate struct {
	Status *string
	CurrentContainerID *string
	CurrentImageRef *string
	CurrentEngineVersion *string
	EngineEndpoint *string
	StatePath *string
	DockerNetwork *string
	TurnSchedule *string
	CurrentTurn *int32
	NextGenerationAt **time.Time
	SkipNextTick *bool
	Paused *bool
	PausedAt **time.Time
	EngineHealth *string
	StartedAt **time.Time
	StoppedAt **time.Time
	FinishedAt **time.Time
	RemovedAt **time.Time
	LastObservedAt **time.Time
}

// UpdateRuntimeRecord patches the supplied columns. Pointer fields are
// translated into a dynamic SET list — only the fields the caller
// supplies are emitted in the UPDATE. Nullable timestamps use a
// `**time.Time` so callers can distinguish "leave alone" (outer nil)
// from "clear to NULL" (inner nil).
func (s *Store) UpdateRuntimeRecord(ctx context.Context, gameID uuid.UUID, patch runtimeRecordUpdate, now time.Time) (RuntimeRecord, error) {
	r := table.RuntimeRecords
	rest := []any{}
	if patch.Status != nil {
		rest = append(rest, r.Status.SET(postgres.String(*patch.Status)))
	}
	if patch.CurrentContainerID != nil {
		rest = append(rest, r.CurrentContainerID.SET(nullableStringSetExpr(*patch.CurrentContainerID)))
	}
	if patch.CurrentImageRef != nil {
		rest = append(rest, r.CurrentImageRef.SET(nullableStringSetExpr(*patch.CurrentImageRef)))
	}
	if patch.CurrentEngineVersion != nil {
		rest = append(rest, r.CurrentEngineVersion.SET(nullableStringSetExpr(*patch.CurrentEngineVersion)))
	}
	if patch.EngineEndpoint != nil {
		rest = append(rest, r.EngineEndpoint.SET(postgres.String(*patch.EngineEndpoint)))
	}
	if patch.StatePath != nil {
		rest = append(rest, r.StatePath.SET(nullableStringSetExpr(*patch.StatePath)))
	}
	if patch.DockerNetwork != nil {
		rest = append(rest, r.DockerNetwork.SET(nullableStringSetExpr(*patch.DockerNetwork)))
	}
	if patch.TurnSchedule != nil {
		rest = append(rest, r.TurnSchedule.SET(postgres.String(*patch.TurnSchedule)))
	}
	if patch.CurrentTurn != nil {
		rest = append(rest, r.CurrentTurn.SET(postgres.Int(int64(*patch.CurrentTurn))))
	}
	if patch.NextGenerationAt != nil {
		rest = append(rest, r.NextGenerationAt.SET(timePtrSetExpr(*patch.NextGenerationAt)))
	}
	if patch.SkipNextTick != nil {
		rest = append(rest, r.SkipNextTick.SET(postgres.Bool(*patch.SkipNextTick)))
	}
	if patch.Paused != nil {
		rest = append(rest, r.Paused.SET(postgres.Bool(*patch.Paused)))
	}
	if patch.PausedAt != nil {
		rest = append(rest, r.PausedAt.SET(timePtrSetExpr(*patch.PausedAt)))
	}
	if patch.EngineHealth != nil {
		rest = append(rest, r.EngineHealth.SET(postgres.String(*patch.EngineHealth)))
	}
	if patch.StartedAt != nil {
		rest = append(rest, r.StartedAt.SET(timePtrSetExpr(*patch.StartedAt)))
	}
	if patch.StoppedAt != nil {
		rest = append(rest, r.StoppedAt.SET(timePtrSetExpr(*patch.StoppedAt)))
	}
	if patch.FinishedAt != nil {
		rest = append(rest, r.FinishedAt.SET(timePtrSetExpr(*patch.FinishedAt)))
	}
	if patch.RemovedAt != nil {
		rest = append(rest, r.RemovedAt.SET(timePtrSetExpr(*patch.RemovedAt)))
	}
	if patch.LastObservedAt != nil {
		rest = append(rest, r.LastObservedAt.SET(timePtrSetExpr(*patch.LastObservedAt)))
	}

	stmt := r.UPDATE().
		SET(r.UpdatedAt.SET(postgres.TimestampzT(now)), rest...).
		WHERE(r.GameID.EQ(postgres.UUID(gameID))).
		RETURNING(runtimeRecordColumns())

	var row model.RuntimeRecords
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return RuntimeRecord{}, ErrNotFound
		}
		return RuntimeRecord{}, fmt.Errorf("runtime store: update runtime_record %s: %w", gameID, err)
	}
	return modelToRuntimeRecord(row), nil
}

// DeleteRuntimeRecord removes the row at gameID. Idempotent: nil when
// no row matched.
func (s *Store) DeleteRuntimeRecord(ctx context.Context, gameID uuid.UUID) error {
	stmt := table.RuntimeRecords.DELETE().
		WHERE(table.RuntimeRecords.GameID.EQ(postgres.UUID(gameID)))
	if _, err := stmt.ExecContext(ctx, s.db); err != nil {
		return fmt.Errorf("runtime store: delete runtime_record %s: %w", gameID, err)
	}
	return nil
}

// =====================================================================
// Player mappings
// =====================================================================

// InsertPlayerMappings persists a slice of mappings in a single
// transaction. Existing rows for the (game_id, user_id) pair are
// replaced (ON CONFLICT) so re-runs of StartGame after a transient
// failure stay idempotent.
func (s *Store) InsertPlayerMappings(ctx context.Context, mappings []PlayerMapping) error {
	if len(mappings) == 0 {
		return nil
	}
	tx, err := s.db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("runtime store: begin player_mappings tx: %w", err)
	}
	defer func() { _ = tx.Rollback() }()
	pm := table.PlayerMappings
	for _, m := range mappings {
		stmt := pm.INSERT(pm.GameID, pm.UserID, pm.RaceName, pm.EnginePlayerUUID).
			VALUES(m.GameID, m.UserID, m.RaceName, m.EnginePlayerUUID).
			ON_CONFLICT(pm.GameID, pm.UserID).
			DO_UPDATE(postgres.SET(
				pm.RaceName.SET(pm.EXCLUDED.RaceName),
				pm.EnginePlayerUUID.SET(pm.EXCLUDED.EnginePlayerUUID),
			))
		if _, err := stmt.ExecContext(ctx, tx); err != nil {
			if isUniqueViolation(err, playerMappingsRaceUnique) {
				return fmt.Errorf("%w: race name %q duplicated within game", ErrConflict, m.RaceName)
			}
			return fmt.Errorf("runtime store: insert player_mapping %s/%s: %w", m.GameID, m.UserID, err)
		}
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("runtime store: commit player_mappings: %w", err)
	}
	return nil
}

// LoadPlayerMapping returns the mapping for (gameID, userID). Returns
// ErrNotFound when no row matches.
func (s *Store) LoadPlayerMapping(ctx context.Context, gameID, userID uuid.UUID) (PlayerMapping, error) {
	pm := table.PlayerMappings
	stmt := postgres.SELECT(pm.GameID, pm.UserID, pm.RaceName, pm.EnginePlayerUUID, pm.CreatedAt).
		FROM(pm).
		WHERE(
			pm.GameID.EQ(postgres.UUID(gameID)).
				AND(pm.UserID.EQ(postgres.UUID(userID))),
		).
		LIMIT(1)
	var row model.PlayerMappings
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return PlayerMapping{}, ErrNotFound
		}
		return PlayerMapping{}, fmt.Errorf("runtime store: load player_mapping: %w", err)
	}
	return modelToPlayerMapping(row), nil
}

// ListPlayerMappingsForGame returns every mapping for gameID.
func (s *Store) ListPlayerMappingsForGame(ctx context.Context, gameID uuid.UUID) ([]PlayerMapping, error) {
	pm := table.PlayerMappings
	stmt := postgres.SELECT(pm.GameID, pm.UserID, pm.RaceName, pm.EnginePlayerUUID, pm.CreatedAt).
		FROM(pm).
		WHERE(pm.GameID.EQ(postgres.UUID(gameID))).
		ORDER_BY(pm.RaceName.ASC())
	var rows []model.PlayerMappings
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("runtime store: list player_mappings: %w", err)
	}
	out := make([]PlayerMapping, 0, len(rows))
	for _, row := range rows {
		out = append(out, modelToPlayerMapping(row))
	}
	return out, nil
}

// DeletePlayerMappingsForGame removes every mapping for gameID. Used
// on stop / cancel / reconciler-removal so a future StartGame can
// repopulate the projection without violating the per-game UNIQUE.
func (s *Store) DeletePlayerMappingsForGame(ctx context.Context, gameID uuid.UUID) error {
	stmt := table.PlayerMappings.DELETE().
		WHERE(table.PlayerMappings.GameID.EQ(postgres.UUID(gameID)))
	if _, err := stmt.ExecContext(ctx, s.db); err != nil {
		return fmt.Errorf("runtime store: delete player_mappings %s: %w", gameID, err)
	}
	return nil
}

// =====================================================================
// Operation log
// =====================================================================

// operationLogInsert carries the parameters for InsertOperationLog.
type operationLogInsert struct {
	OperationID uuid.UUID
	GameID uuid.UUID
	Op string
	Source string
	Status string
	ImageRef string
	ContainerID string
	StartedAt time.Time
}

// InsertOperationLog persists a queued / running operation row.
func (s *Store) InsertOperationLog(ctx context.Context, in operationLogInsert) (OperationLog, error) {
	o := table.RuntimeOperationLog
	stmt := o.INSERT(
		o.OperationID, o.GameID, o.Op, o.Source, o.Status, o.ImageRef,
		o.ContainerID, o.StartedAt,
	).VALUES(
		in.OperationID, in.GameID, in.Op, in.Source, in.Status, in.ImageRef,
		in.ContainerID, in.StartedAt,
	).RETURNING(operationLogColumns())
	var row model.RuntimeOperationLog
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		return OperationLog{}, err
	}
	return modelToOperationLog(row), nil
}

// CompleteOperationLog updates the status / error fields on
// operationID. Returns the refreshed row.
func (s *Store) CompleteOperationLog(ctx context.Context, operationID uuid.UUID, status, errCode, errMsg string, finishedAt time.Time) (OperationLog, error) {
	o := table.RuntimeOperationLog
	stmt := o.UPDATE().
		SET(
			o.Status.SET(postgres.String(status)),
			o.ErrorCode.SET(postgres.String(errCode)),
			o.ErrorMessage.SET(postgres.String(errMsg)),
			o.FinishedAt.SET(postgres.TimestampzT(finishedAt)),
		).
		WHERE(o.OperationID.EQ(postgres.UUID(operationID))).
		RETURNING(operationLogColumns())
	var row model.RuntimeOperationLog
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return OperationLog{}, ErrNotFound
		}
		return OperationLog{}, fmt.Errorf("runtime store: complete operation_log %s: %w", operationID, err)
	}
	return modelToOperationLog(row), nil
}

// =====================================================================
// Health snapshots
// =====================================================================

// InsertHealthSnapshot persists a JSON-encoded engine status snapshot.
func (s *Store) InsertHealthSnapshot(ctx context.Context, snapshotID, gameID uuid.UUID, observedAt time.Time, payload []byte) error {
	hs := table.RuntimeHealthSnapshots
	stmt := hs.INSERT(hs.SnapshotID, hs.GameID, hs.ObservedAt, hs.Payload).
		VALUES(snapshotID, gameID, observedAt, string(payload))
	if _, err := stmt.ExecContext(ctx, s.db); err != nil {
		return fmt.Errorf("runtime store: insert health_snapshot %s: %w", gameID, err)
	}
	return nil
}

// =====================================================================
// Read-only lobby projection (per The implementation D2)
// =====================================================================

// LoadGameProjection reads `backend.games` for runtime's start/stop
// flow. Lobby remains the only writer of the table; runtime is a
// read-only consumer. Returns ErrNotFound on miss.
func (s *Store) LoadGameProjection(ctx context.Context, gameID uuid.UUID) (Game, error) {
	g := table.Games
	stmt := postgres.SELECT(
		g.GameID, g.OwnerUserID, g.Visibility, g.Status, g.GameName,
		g.TurnSchedule, g.TargetEngineVersion,
		g.MinPlayers, g.MaxPlayers, g.StartGapHours, g.StartGapPlayers,
	).
		FROM(g).
		WHERE(g.GameID.EQ(postgres.UUID(gameID))).
		LIMIT(1)
	var row model.Games
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return Game{}, ErrNotFound
		}
		return Game{}, fmt.Errorf("runtime store: load game %s: %w", gameID, err)
	}
	out := Game{
		GameID: row.GameID,
		Visibility: row.Visibility,
		Status: row.Status,
		GameName: row.GameName,
		TurnSchedule: row.TurnSchedule,
		TargetEngineVersion: row.TargetEngineVersion,
		MinPlayers: row.MinPlayers,
		MaxPlayers: row.MaxPlayers,
		StartGapHours: row.StartGapHours,
		StartGapPlayers: row.StartGapPlayers,
	}
	if row.OwnerUserID != nil {
		owner := *row.OwnerUserID
		out.OwnerUserID = &owner
	}
	return out, nil
}

// ListActiveMemberships reads active rows from `backend.memberships`
// for gameID.
func (s *Store) ListActiveMemberships(ctx context.Context, gameID uuid.UUID) ([]MembershipRow, error) {
	m := table.Memberships
	stmt := postgres.SELECT(m.MembershipID, m.GameID, m.UserID, m.RaceName).
		FROM(m).
		WHERE(
			m.GameID.EQ(postgres.UUID(gameID)).
				AND(m.Status.EQ(postgres.String("active"))),
		).
		ORDER_BY(m.JoinedAt.ASC())
	var rows []model.Memberships
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("runtime store: list memberships %s: %w", gameID, err)
	}
	out := make([]MembershipRow, 0, len(rows))
	for _, row := range rows {
		out = append(out, MembershipRow{
			MembershipID: row.MembershipID,
			GameID: row.GameID,
			UserID: row.UserID,
			RaceName: row.RaceName,
		})
	}
	return out, nil
}

// =====================================================================
// Model → domain converters
// =====================================================================

func modelToEngineVersion(row model.EngineVersions) EngineVersion {
	return EngineVersion{
		Version: row.Version,
		ImageRef: row.ImageRef,
		Enabled: row.Enabled,
		CreatedAt: row.CreatedAt,
		UpdatedAt: row.UpdatedAt,
	}
}

func modelToRuntimeRecord(row model.RuntimeRecords) RuntimeRecord {
	rec := RuntimeRecord{
		GameID: row.GameID,
		Status: row.Status,
		EngineEndpoint: row.EngineEndpoint,
		TurnSchedule: row.TurnSchedule,
		CurrentTurn: row.CurrentTurn,
		SkipNextTick: row.SkipNextTick,
		Paused: row.Paused,
		EngineHealth: row.EngineHealth,
		CreatedAt: row.CreatedAt,
		UpdatedAt: row.UpdatedAt,
		CurrentContainerID: derefString(row.CurrentContainerID),
		CurrentImageRef: derefString(row.CurrentImageRef),
		CurrentEngineVersion: derefString(row.CurrentEngineVersion),
		StatePath: derefString(row.StatePath),
		DockerNetwork: derefString(row.DockerNetwork),
	}
	rec.NextGenerationAt = copyTimePtr(row.NextGenerationAt)
	rec.PausedAt = copyTimePtr(row.PausedAt)
	rec.StartedAt = copyTimePtr(row.StartedAt)
	rec.StoppedAt = copyTimePtr(row.StoppedAt)
	rec.FinishedAt = copyTimePtr(row.FinishedAt)
	rec.RemovedAt = copyTimePtr(row.RemovedAt)
	rec.LastObservedAt = copyTimePtr(row.LastObservedAt)
	return rec
}

func modelToOperationLog(row model.RuntimeOperationLog) OperationLog {
	op := OperationLog{
		OperationID: row.OperationID,
		GameID: row.GameID,
		Op: row.Op,
		Source: row.Source,
		Status: row.Status,
		ImageRef: row.ImageRef,
		ContainerID: row.ContainerID,
		ErrorCode: row.ErrorCode,
		ErrorMessage: row.ErrorMessage,
		StartedAt: row.StartedAt,
	}
	op.FinishedAt = copyTimePtr(row.FinishedAt)
	return op
}

func modelToPlayerMapping(row model.PlayerMappings) PlayerMapping {
	return PlayerMapping{
		GameID: row.GameID,
		UserID: row.UserID,
		RaceName: row.RaceName,
		EnginePlayerUUID: row.EnginePlayerUUID,
		CreatedAt: row.CreatedAt,
	}
}

// =====================================================================
// Scalar helpers
// =====================================================================

// nullableString converts a Go string to the `any` form expected by
// jet INSERT VALUES bindings: an empty string becomes nil so the
// column receives NULL.
func nullableString(s string) any {
	if s == "" {
		return nil
	}
	return s
}

// nullableTime mirrors nullableString for *time.Time.
func nullableTime(t *time.Time) any {
	if t == nil {
		return nil
	}
	return *t
}

// nullableStringSetExpr returns a typed jet expression suitable for
// UPDATE SET on a nullable text column. The empty string is mapped to
// SQL NULL, mirroring the INSERT-side semantics so a "" patch clears
// the column.
func nullableStringSetExpr(v string) postgres.StringExpression {
	if v == "" {
		return postgres.StringExp(postgres.NULL)
	}
	return postgres.String(v)
}

// timePtrSetExpr mirrors nullableStringSetExpr for *time.Time. nil
// clears the column; non-nil sets it.
func timePtrSetExpr(t *time.Time) postgres.TimestampzExpression {
	if t == nil {
		return postgres.TimestampzExp(postgres.NULL)
	}
	return postgres.TimestampzT(*t)
}

func derefString(p *string) string {
	if p == nil {
		return ""
	}
	return *p
}

func copyTimePtr(p *time.Time) *time.Time {
	if p == nil {
		return nil
	}
	t := *p
	return &t
}
