package account

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

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

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

// RetentionTTL bounds how long the account-deletion legal dossier is kept before the
// reaper purges it: two years from the detach/deletion event (owner policy, 2026-07-03).
const RetentionTTL = 2 * 365 * 24 * time.Hour

// Reasons recorded on a retained_identities row: what detached the credential from its
// account (unlink / email change / account deletion here; an account merge that drops a
// same-kind colliding identity writes reason "merge" from the accountmerge package). The
// row is written just before the live identities row is removed, preserving the legal
// dossier (which email/vk/tg was linked, and when) even as the identity frees for reuse.
// See docs/ARCHITECTURE.md §9.1.
const (
	retainUnlink = "unlink"
	retainChange = "change"
	retainDelete = "delete"
)

// retainIdentityTx appends a retention-journal row for one identity being detached, inside
// tx. linkedAt is the identity's original creation time; detached_at defaults to now(). It
// must run in the same transaction as the identity removal, so the dossier and the live
// state can never diverge.
func retainIdentityTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind, externalID string, confirmed bool, linkedAt time.Time, reason string) error {
	id, err := uuid.NewV7()
	if err != nil {
		return fmt.Errorf("account: new retained id: %w", err)
	}
	ins := table.RetainedIdentities.INSERT(
		table.RetainedIdentities.RetainedID, table.RetainedIdentities.AccountID,
		table.RetainedIdentities.Kind, table.RetainedIdentities.ExternalID,
		table.RetainedIdentities.Confirmed, table.RetainedIdentities.LinkedAt,
		table.RetainedIdentities.Reason,
	).VALUES(id, accountID, kind, externalID, confirmed, linkedAt, reason)
	if _, err := ins.ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("account: retain identity (%s, %s): %w", kind, externalID, err)
	}
	return nil
}

// StampLastLogin records the account's last cold-load time and client IP, but only when
// the stored value is missing or older than an hour — so it costs at most one write per
// account per hour (its caller, the profile fetch, runs once per cold app-load). It is a
// best-effort audit signal that feeds the account-deletion dossier.
func (s *Store) StampLastLogin(ctx context.Context, accountID uuid.UUID, ip string) error {
	now := time.Now().UTC()
	upd := table.Accounts.UPDATE(table.Accounts.LastLoginAt, table.Accounts.LastLoginIP).
		SET(postgres.TimestampzT(now), postgres.String(ip)).
		WHERE(
			table.Accounts.AccountID.EQ(postgres.UUID(accountID)).
				AND(
					table.Accounts.LastLoginAt.IS_NULL().
						OR(table.Accounts.LastLoginAt.LT(postgres.TimestampzT(now.Add(-time.Hour)))),
				),
		)
	if _, err := upd.ExecContext(ctx, s.db); err != nil {
		return fmt.Errorf("account: stamp last login %s: %w", accountID, err)
	}
	return nil
}

// ReapExpiredRetention purges retention data whose event is older than cutoff: every
// retained_identities row by its detached_at (covering unlink/change on live accounts as
// well as deleted ones), plus — for accounts tombstoned before cutoff — the retained
// feedback thread and the dossier PII (deleted_display_name, last_login_ip). Chat is kept
// (a shared game artifact), and the tombstone account row itself stays (its no-cascade
// foreign keys). It returns how many journal rows and feedback messages were removed.
func (s *Store) ReapExpiredRetention(ctx context.Context, cutoff time.Time) (identities, feedback int64, err error) {
	cut := postgres.TimestampzT(cutoff)
	delJournal := table.RetainedIdentities.DELETE().
		WHERE(table.RetainedIdentities.DetachedAt.LT(cut))
	res, err := delJournal.ExecContext(ctx, s.db)
	if err != nil {
		return 0, 0, fmt.Errorf("account: reap retained identities: %w", err)
	}
	identities, _ = res.RowsAffected()

	expired := postgres.SELECT(table.Accounts.AccountID).
		FROM(table.Accounts).
		WHERE(table.Accounts.DeletedAt.IS_NOT_NULL().AND(table.Accounts.DeletedAt.LT(cut)))
	delFeedback := table.FeedbackMessages.DELETE().
		WHERE(table.FeedbackMessages.AccountID.IN(expired))
	fbRes, err := delFeedback.ExecContext(ctx, s.db)
	if err != nil {
		return identities, 0, fmt.Errorf("account: reap deleted feedback: %w", err)
	}
	feedback, _ = fbRes.RowsAffected()

	clearPII := table.Accounts.UPDATE(table.Accounts.DeletedDisplayName, table.Accounts.LastLoginIP).
		SET(postgres.NULL, postgres.NULL).
		WHERE(
			table.Accounts.DeletedAt.IS_NOT_NULL().
				AND(table.Accounts.DeletedAt.LT(cut)).
				AND(table.Accounts.DeletedDisplayName.IS_NOT_NULL().
					OR(table.Accounts.LastLoginIP.IS_NOT_NULL())),
		)
	if _, err := clearPII.ExecContext(ctx, s.db); err != nil {
		return identities, feedback, fmt.Errorf("account: clear expired dossier PII: %w", err)
	}
	return identities, feedback, nil
}

// RetainedIdentity is one row of the retention journal, for the admin dossier.
type RetainedIdentity struct {
	Kind       string
	ExternalID string
	Reason     string
	Confirmed  bool
	LinkedAt   time.Time
	DetachedAt time.Time
}

// RetainedIdentities returns the account's retention-journal rows (the legal dossier of
// detached credentials), newest detach first, for the admin console.
func (s *Store) RetainedIdentities(ctx context.Context, accountID uuid.UUID) ([]RetainedIdentity, error) {
	var rows []model.RetainedIdentities
	err := postgres.SELECT(table.RetainedIdentities.AllColumns).
		FROM(table.RetainedIdentities).
		WHERE(table.RetainedIdentities.AccountID.EQ(postgres.UUID(accountID))).
		ORDER_BY(table.RetainedIdentities.DetachedAt.DESC()).
		QueryContext(ctx, s.db, &rows)
	if err != nil && !errors.Is(err, qrm.ErrNoRows) {
		return nil, fmt.Errorf("account: retained identities %s: %w", accountID, err)
	}
	out := make([]RetainedIdentity, 0, len(rows))
	for _, r := range rows {
		out = append(out, RetainedIdentity{
			Kind: r.Kind, ExternalID: r.ExternalID, Reason: r.Reason,
			Confirmed: r.Confirmed, LinkedAt: r.LinkedAt, DetachedAt: r.DetachedAt,
		})
	}
	return out, nil
}

// DeletionInfo is a tombstoned account's dossier header, for the admin console.
type DeletionInfo struct {
	DeletedAt          *time.Time
	DeletedDisplayName string
	LastLoginAt        *time.Time
	LastLoginIP        string
}

// DeletionInfo reads the account's deletion tombstone + last-login dossier fields.
func (s *Store) DeletionInfo(ctx context.Context, accountID uuid.UUID) (DeletionInfo, error) {
	var row model.Accounts
	err := postgres.SELECT(
		table.Accounts.DeletedAt, table.Accounts.DeletedDisplayName,
		table.Accounts.LastLoginAt, table.Accounts.LastLoginIP,
	).FROM(table.Accounts).
		WHERE(table.Accounts.AccountID.EQ(postgres.UUID(accountID))).
		QueryContext(ctx, s.db, &row)
	if err != nil {
		if errors.Is(err, qrm.ErrNoRows) {
			return DeletionInfo{}, ErrNotFound
		}
		return DeletionInfo{}, fmt.Errorf("account: deletion info %s: %w", accountID, err)
	}
	info := DeletionInfo{DeletedAt: row.DeletedAt, LastLoginAt: row.LastLoginAt}
	if row.DeletedDisplayName != nil {
		info.DeletedDisplayName = *row.DeletedDisplayName
	}
	if row.LastLoginIP != nil {
		info.LastLoginIP = *row.LastLoginIP
	}
	return info, nil
}

// DeletedIDs returns the subset of ids whose accounts are tombstoned (deleted_at set), as a
// set. It is the batch form of Account.Deleted for a caller holding several ids at once —
// the game views resolve every seat's deleted mark in one round-trip. An empty ids slice
// queries nothing and returns an empty set; an unknown id is simply absent from the result.
func (s *Store) DeletedIDs(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]bool, error) {
	out := map[uuid.UUID]bool{}
	if len(ids) == 0 {
		return out, nil
	}
	exprs := make([]postgres.Expression, len(ids))
	for i, id := range ids {
		exprs[i] = postgres.UUID(id)
	}
	stmt := postgres.SELECT(table.Accounts.AccountID).
		FROM(table.Accounts).
		WHERE(table.Accounts.AccountID.IN(exprs...).AND(table.Accounts.DeletedAt.IS_NOT_NULL()))
	var rows []model.Accounts
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("account: deleted ids: %w", err)
	}
	for _, r := range rows {
		out[r.AccountID] = true
	}
	return out, nil
}

// RetentionReaper periodically purges expired account-deletion retention data via
// Store.ReapExpiredRetention, mirroring GuestReaper: one background goroutine started once
// from main.
type RetentionReaper struct {
	store *Store
	ttl   time.Duration
	clock func() time.Time
	log   *zap.Logger
}

// NewRetentionReaper constructs a reaper purging retention data older than ttl. log may be
// nil.
func NewRetentionReaper(store *Store, ttl time.Duration, log *zap.Logger) *RetentionReaper {
	if log == nil {
		log = zap.NewNop()
	}
	return &RetentionReaper{
		store: store,
		ttl:   ttl,
		clock: func() time.Time { return time.Now().UTC() },
		log:   log,
	}
}

// Run purges expired retention data on each tick until ctx is cancelled.
func (r *RetentionReaper) Run(ctx context.Context, interval time.Duration) {
	ticker := time.NewTicker(interval)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			idn, fb, err := r.store.ReapExpiredRetention(ctx, r.clock().Add(-r.ttl))
			if err != nil {
				r.log.Warn("retention reap failed", zap.Error(err))
			} else if idn > 0 || fb > 0 {
				r.log.Info("reaped expired retention", zap.Int64("identities", idn), zap.Int64("feedback", fb))
			}
		}
	}
}
