package account

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"time"

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

	"scrabble/backend/internal/postgres/jet/backend/model"
	"scrabble/backend/internal/postgres/jet/backend/table"
)

// Suspension is an account's currently-in-force manual block, the operator's hard counterpart
// to the soft, reversible FlaggedHighRateAt marker. It is named "suspension" to stay distinct
// from the peer-to-peer blocks in internal/social (one player muting another); the vocabulary
// the player sees is "blocked". BlockedUntil is nil for a permanent block and the expiry
// instant for a temporary one. ReasonEn and ReasonRu are the reason-text snapshot taken at
// block time (both empty when no reason was cited), so editing or deleting the picklist entry
// never changes what an already-blocked player is shown.
type Suspension struct {
	AccountID    uuid.UUID
	BlockedAt    time.Time
	BlockedUntil *time.Time
	ReasonEn     string
	ReasonRu     string
}

// Permanent reports whether the suspension has no expiry.
func (s Suspension) Permanent() bool { return s.BlockedUntil == nil }

// LocalizedReason returns the reason text in the given language ("ru" selects Russian, anything
// else English), or empty when no reason was cited. The two snapshots are always both set or
// both empty, since a reason is chosen from the en+ru picklist.
func (s Suspension) LocalizedReason(language string) string {
	if language == "ru" {
		return s.ReasonRu
	}
	return s.ReasonEn
}

// Reason is one entry of the operator-editable suspension-reason picklist, carrying the English
// and Russian text shown to a blocked player in their language.
type Reason struct {
	ID        uuid.UUID
	TextEn    string
	TextRu    string
	CreatedAt time.Time
	UpdatedAt time.Time
}

// Suspend records a new manual block on the account: permanent when until is nil, otherwise in
// force until that instant. reasonEn and reasonRu are the optional reason-text snapshot (both
// empty for no reason) and reasonID is the loose picklist link (nil when none, nulled later if
// that entry is deleted). It returns the persisted Suspension. Suspending an already-blocked
// account simply appends another block; CurrentSuspension always reflects the strongest.
func (s *Store) Suspend(ctx context.Context, accountID uuid.UUID, until *time.Time, reasonEn, reasonRu string, reasonID *uuid.UUID) (Suspension, error) {
	suspensionID, err := uuid.NewV7()
	if err != nil {
		return Suspension{}, fmt.Errorf("account: new suspension id: %w", err)
	}
	stmt := table.AccountSuspensions.INSERT(
		table.AccountSuspensions.SuspensionID,
		table.AccountSuspensions.AccountID,
		table.AccountSuspensions.BlockedUntil,
		table.AccountSuspensions.ReasonEn,
		table.AccountSuspensions.ReasonRu,
		table.AccountSuspensions.ReasonID,
	).VALUES(
		suspensionID,
		accountID,
		nullableTimestamp(until),
		nullableString(reasonEn),
		nullableString(reasonRu),
		nullableUUID(reasonID),
	).RETURNING(table.AccountSuspensions.AllColumns)

	var row model.AccountSuspensions
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		return Suspension{}, fmt.Errorf("account: suspend %s: %w", accountID, err)
	}
	s.invalidateSuspension(accountID)
	return modelToSuspension(row), nil
}

// LiftSuspension lifts every in-force block on the account (the operator's manual unblock),
// stamping lifted_at on each. It is a no-op when the account is not currently blocked. Lifting
// does not un-resign games already forfeited at block time — those stay lost.
func (s *Store) LiftSuspension(ctx context.Context, accountID uuid.UUID) error {
	now := time.Now().UTC()
	stmt := table.AccountSuspensions.
		UPDATE(table.AccountSuspensions.LiftedAt).
		SET(postgres.TimestampzT(now)).
		WHERE(
			table.AccountSuspensions.AccountID.EQ(postgres.UUID(accountID)).
				AND(activeSuspensionPredicate(now)),
		)
	if _, err := stmt.ExecContext(ctx, s.db); err != nil {
		return fmt.Errorf("account: lift suspension %s: %w", accountID, err)
	}
	s.invalidateSuspension(accountID)
	return nil
}

// CurrentSuspension returns the account's in-force block and true, or false when the account is
// not blocked. When several blocks overlap it returns the strongest — a permanent one first,
// otherwise the latest-expiring — which is what the player's blocked screen shows. The gate
// middleware calls it on every authenticated request, served by account_suspensions_account_idx.
func (s *Store) CurrentSuspension(ctx context.Context, accountID uuid.UUID) (Suspension, bool, error) {
	// A store with no database (the zero-value store used by the routing unit tests) has no
	// suspensions; report not-blocked rather than dereferencing a nil pool. A real pool that
	// errors still surfaces the error to the gate, which fails closed.
	if s.db == nil {
		return Suspension{}, false, nil
	}
	now := time.Now().UTC()
	if s.suspensions != nil {
		if e, ok := s.suspensions.get(accountID); ok {
			if !e.found {
				return Suspension{}, false, nil // cached: not blocked
			}
			if suspensionActiveAt(e.susp, now) {
				return e.susp, true, nil // cached block still in force
			}
			// The cached block has lapsed since it was cached; refresh from the database below.
		}
	}
	susp, found, err := s.queryCurrentSuspension(ctx, accountID, now)
	if err != nil {
		return Suspension{}, false, err
	}
	if s.suspensions != nil {
		s.suspensions.put(accountID, suspensionCacheEntry{susp: susp, found: found})
	}
	return susp, found, nil
}

// queryCurrentSuspension reads the account's strongest in-force block straight from the database
// (no cache), as of now. It backs CurrentSuspension on a cache miss or a lapsed entry.
func (s *Store) queryCurrentSuspension(ctx context.Context, accountID uuid.UUID, now time.Time) (Suspension, bool, error) {
	stmt := postgres.SELECT(table.AccountSuspensions.AllColumns).
		FROM(table.AccountSuspensions).
		WHERE(
			table.AccountSuspensions.AccountID.EQ(postgres.UUID(accountID)).
				AND(activeSuspensionPredicate(now)),
		).
		ORDER_BY(table.AccountSuspensions.BlockedUntil.DESC().NULLS_FIRST()).
		LIMIT(1)

	var row model.AccountSuspensions
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return Suspension{}, false, nil
		}
		return Suspension{}, false, fmt.Errorf("account: current suspension %s: %w", accountID, err)
	}
	return modelToSuspension(row), true, nil
}

// SuspensionsExpiredBetween returns the distinct account ids whose temporary block lapsed in the
// half-open window (since, until]: a non-lifted suspension with a blocked_until in that range. The
// chat-access sweeper uses it to re-evaluate chat write access when a temporary block self-expires,
// since no operator action fires then. An account that still has another active block may be
// included; the eligibility resolver returns the true state, so emitting for it is harmless.
func (s *Store) SuspensionsExpiredBetween(ctx context.Context, since, until time.Time) ([]uuid.UUID, error) {
	rows, err := s.db.QueryContext(ctx,
		`SELECT DISTINCT account_id FROM backend.account_suspensions
		 WHERE lifted_at IS NULL AND blocked_until > $1 AND blocked_until <= $2`,
		since.UTC(), until.UTC())
	if err != nil {
		return nil, fmt.Errorf("account: suspensions expired between: %w", err)
	}
	defer rows.Close()
	var out []uuid.UUID
	for rows.Next() {
		var id uuid.UUID
		if err := rows.Scan(&id); err != nil {
			return nil, fmt.Errorf("account: scan expired suspension: %w", err)
		}
		out = append(out, id)
	}
	return out, rows.Err()
}

// invalidateSuspension drops the account's cached block so the next CurrentSuspension re-reads it.
// Called after Suspend and LiftSuspension.
func (s *Store) invalidateSuspension(accountID uuid.UUID) {
	if s.suspensions != nil {
		s.suspensions.invalidate(accountID)
	}
}

// suspensionActiveAt reports whether a suspension is in force at now: permanent, or not yet
// expired. The lifted check is implicit — only non-lifted blocks are ever cached or returned.
func suspensionActiveAt(susp Suspension, now time.Time) bool {
	return susp.BlockedUntil == nil || susp.BlockedUntil.After(now)
}

// suspensionCache is the gate's write-through cache of each account's current block, keyed by
// account id. The suspension gate reads it on every authenticated request; Suspend and
// LiftSuspension invalidate the account's entry. An entry holds the strongest active block at
// query time (or a not-blocked marker), re-evaluated against the wall clock on read, so a
// temporary block lapses without an explicit invalidation. It is single-instance, matching the
// deployment (one shared Store); a multi-instance deployment would need a shared cache.
type suspensionCache struct {
	mu sync.RWMutex
	m  map[uuid.UUID]suspensionCacheEntry
}

// suspensionCacheEntry is a cached lookup: the strongest active block when found, else a
// not-blocked marker (found=false).
type suspensionCacheEntry struct {
	susp  Suspension
	found bool
}

func newSuspensionCache() *suspensionCache {
	return &suspensionCache{m: make(map[uuid.UUID]suspensionCacheEntry)}
}

func (c *suspensionCache) get(id uuid.UUID) (suspensionCacheEntry, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	e, ok := c.m[id]
	return e, ok
}

func (c *suspensionCache) put(id uuid.UUID, e suspensionCacheEntry) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.m[id] = e
}

func (c *suspensionCache) invalidate(id uuid.UUID) {
	c.mu.Lock()
	defer c.mu.Unlock()
	delete(c.m, id)
}

// activeSuspensionPredicate matches the rows of a block that is in force at now: not lifted and
// either permanent or not yet expired.
func activeSuspensionPredicate(now time.Time) postgres.BoolExpression {
	return table.AccountSuspensions.LiftedAt.IS_NULL().
		AND(
			table.AccountSuspensions.BlockedUntil.IS_NULL().
				OR(table.AccountSuspensions.BlockedUntil.GT(postgres.TimestampzT(now))),
		)
}

// ListReasons returns the suspension-reason picklist, oldest first.
func (s *Store) ListReasons(ctx context.Context) ([]Reason, error) {
	stmt := postgres.SELECT(table.SuspensionReasons.AllColumns).
		FROM(table.SuspensionReasons).
		ORDER_BY(table.SuspensionReasons.CreatedAt.ASC())
	var rows []model.SuspensionReasons
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("account: list reasons: %w", err)
	}
	out := make([]Reason, 0, len(rows))
	for _, r := range rows {
		out = append(out, modelToReason(r))
	}
	return out, nil
}

// GetReason loads one picklist entry, or ErrNotFound when it is absent.
func (s *Store) GetReason(ctx context.Context, id uuid.UUID) (Reason, error) {
	stmt := postgres.SELECT(table.SuspensionReasons.AllColumns).
		FROM(table.SuspensionReasons).
		WHERE(table.SuspensionReasons.ReasonID.EQ(postgres.UUID(id))).
		LIMIT(1)
	var row model.SuspensionReasons
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return Reason{}, ErrNotFound
		}
		return Reason{}, fmt.Errorf("account: get reason %s: %w", id, err)
	}
	return modelToReason(row), nil
}

// CreateReason inserts a new picklist entry with the given English and Russian text.
func (s *Store) CreateReason(ctx context.Context, textEn, textRu string) (Reason, error) {
	id, err := uuid.NewV7()
	if err != nil {
		return Reason{}, fmt.Errorf("account: new reason id: %w", err)
	}
	stmt := table.SuspensionReasons.INSERT(
		table.SuspensionReasons.ReasonID,
		table.SuspensionReasons.TextEn,
		table.SuspensionReasons.TextRu,
	).VALUES(id, textEn, textRu).
		RETURNING(table.SuspensionReasons.AllColumns)
	var row model.SuspensionReasons
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		return Reason{}, fmt.Errorf("account: create reason: %w", err)
	}
	return modelToReason(row), nil
}

// UpdateReason rewrites a picklist entry's English and Russian text, returning ErrNotFound when
// it is absent. Existing suspensions keep their text snapshot, so the change only affects future
// blocks.
func (s *Store) UpdateReason(ctx context.Context, id uuid.UUID, textEn, textRu string) (Reason, error) {
	stmt := table.SuspensionReasons.
		UPDATE(table.SuspensionReasons.TextEn, table.SuspensionReasons.TextRu, table.SuspensionReasons.UpdatedAt).
		SET(postgres.String(textEn), postgres.String(textRu), postgres.TimestampzT(time.Now().UTC())).
		WHERE(table.SuspensionReasons.ReasonID.EQ(postgres.UUID(id))).
		RETURNING(table.SuspensionReasons.AllColumns)
	var row model.SuspensionReasons
	if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return Reason{}, ErrNotFound
		}
		return Reason{}, fmt.Errorf("account: update reason %s: %w", id, err)
	}
	return modelToReason(row), nil
}

// DeleteReason removes a picklist entry. It is a hard delete: the reason_id link on past
// suspensions is nulled by the foreign key, but their text snapshot is untouched.
func (s *Store) DeleteReason(ctx context.Context, id uuid.UUID) error {
	stmt := table.SuspensionReasons.
		DELETE().
		WHERE(table.SuspensionReasons.ReasonID.EQ(postgres.UUID(id)))
	if _, err := stmt.ExecContext(ctx, s.db); err != nil {
		return fmt.Errorf("account: delete reason %s: %w", id, err)
	}
	return nil
}

// modelToSuspension projects a generated row into the public Suspension struct.
func modelToSuspension(row model.AccountSuspensions) Suspension {
	s := Suspension{AccountID: row.AccountID, BlockedAt: row.BlockedAt, BlockedUntil: row.BlockedUntil}
	if row.ReasonEn != nil {
		s.ReasonEn = *row.ReasonEn
	}
	if row.ReasonRu != nil {
		s.ReasonRu = *row.ReasonRu
	}
	return s
}

// modelToReason projects a generated row into the public Reason struct.
func modelToReason(row model.SuspensionReasons) Reason {
	return Reason{ID: row.ReasonID, TextEn: row.TextEn, TextRu: row.TextRu, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
}

// nullableString renders an empty string as SQL NULL, otherwise the string literal.
func nullableString(v string) postgres.Expression {
	if v == "" {
		return postgres.NULL
	}
	return postgres.String(v)
}

// nullableTimestamp renders a nil time as SQL NULL, otherwise the UTC timestamp literal.
func nullableTimestamp(v *time.Time) postgres.Expression {
	if v == nil {
		return postgres.NULL
	}
	return postgres.TimestampzT(v.UTC())
}

// nullableUUID renders a nil id as SQL NULL, otherwise the uuid literal.
func nullableUUID(v *uuid.UUID) postgres.Expression {
	if v == nil {
		return postgres.NULL
	}
	return postgres.UUID(*v)
}
