// Package accountdelete deactivates an account as legal retention, not erasure: it keeps
// the account row as a tombstone (its chat/complaint foreign keys have no cascade, so a
// hard delete is impossible) while journalling and freeing the account's credentials,
// anonymising the live surfaces, and dropping the account's own social/ephemeral rows.
// The retained_identities journal plus the tombstone (deleted_at, deleted_display_name,
// last_login_at/ip) form the admin/legal dossier; messages are deliberately kept. Session
// revocation and active-game forfeit are orchestrated one layer up (they need the session
// cache and the game service). See docs/ARCHITECTURE.md §9.1 and the retention TTL reaper.
package accountdelete

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"

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

// AnonymizedName is the label a deleted account shows to opponents. Display names are
// stored strings resolved identically for every viewer (no per-viewer localisation in this
// codebase), so a single canonical label is used. The brackets are deliberate: the
// editable-name rule (account.displayNameRe) forbids them, so a live player can never set a
// name that impersonates a deleted account.
const AnonymizedName = "[Deleted]"

// retainDelete is the retained_identities reason written when a credential is journalled
// because its account is being deleted.
const retainDelete = "delete"

// Deleter performs the SQL-atomic part of account deletion over a Postgres handle.
type Deleter struct {
	db  *sql.DB
	now func() time.Time
}

// NewDeleter constructs a Deleter over db.
func NewDeleter(db *sql.DB) *Deleter {
	return &Deleter{db: db, now: func() time.Time { return time.Now().UTC() }}
}

// AnonymizeAndTombstone retires accountID atomically: it journals every live identity into
// retained_identities (reason=delete) then removes them so the credentials free for reuse,
// snapshots the real display name into deleted_display_name and scrubs the live one to
// AnonymizedName, sets deleted_at, anonymises the account's game-seat snapshots, and drops
// its friendships, blocks, invitations, friend codes, drafts and pending codes. Chat,
// feedback and complaints are kept (the surviving tombstone keeps their no-cascade foreign
// keys valid). It is idempotent-safe on an already-tombstoned account (re-journalling
// nothing, since the identities are already gone).
func (d *Deleter) AnonymizeAndTombstone(ctx context.Context, accountID uuid.UUID) error {
	now := d.now()
	return withTx(ctx, d.db, func(tx *sql.Tx) error {
		if err := journalAndDropIdentities(ctx, tx, accountID, now); err != nil {
			return err
		}
		if err := tombstone(ctx, tx, accountID, now); err != nil {
			return err
		}
		if _, err := table.GamePlayers.UPDATE(table.GamePlayers.DisplayName).
			SET(postgres.String(AnonymizedName)).
			WHERE(table.GamePlayers.AccountID.EQ(postgres.UUID(accountID))).
			ExecContext(ctx, tx); err != nil {
			return fmt.Errorf("accountdelete: anonymise seats: %w", err)
		}
		return dropSocialAndEphemerals(ctx, tx, accountID)
	})
}

// dropAllRobotGamesSQL deletes every game in which the account plays and no other seat is a
// human — a robot seat is one whose account holds a 'robot' identity, so this covers both
// honest vs-AI games and disguised auto-match substitutes. The game rows are deleted; their
// moves/chat/players/complaints fall away through ON DELETE CASCADE.
const dropAllRobotGamesSQL = `
DELETE FROM games g
WHERE EXISTS (
    SELECT 1 FROM game_players p WHERE p.game_id = g.game_id AND p.account_id = $1
) AND NOT EXISTS (
    SELECT 1 FROM game_players o
    WHERE o.game_id = g.game_id AND o.account_id <> $1
      AND NOT EXISTS (
          SELECT 1 FROM identities i WHERE i.account_id = o.account_id AND i.kind = 'robot'
      )
)`

// DropAllRobotGames deletes the account's games that have no human opponent (solo vs-AI or
// auto-match-robot games), returning how many were removed. Games with any human seat are
// kept — their seat is anonymised by AnonymizeAndTombstone instead. Run it after the
// account's active games are resigned, so no live game is removed under the robot driver.
func (d *Deleter) DropAllRobotGames(ctx context.Context, accountID uuid.UUID) (int64, error) {
	res, err := d.db.ExecContext(ctx, dropAllRobotGamesSQL, accountID)
	if err != nil {
		return 0, fmt.Errorf("accountdelete: drop all-robot games: %w", err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return 0, fmt.Errorf("accountdelete: dropped games count: %w", err)
	}
	return n, nil
}

// journalAndDropIdentities copies the account's live identities into the retention journal
// (reason=delete) and then removes them, freeing each (kind, external_id) for reuse.
func journalAndDropIdentities(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, now time.Time) error {
	var ids []model.Identities
	err := postgres.SELECT(table.Identities.AllColumns).
		FROM(table.Identities).
		WHERE(table.Identities.AccountID.EQ(postgres.UUID(accountID))).
		QueryContext(ctx, tx, &ids)
	if err != nil && !errors.Is(err, qrm.ErrNoRows) {
		return fmt.Errorf("accountdelete: load identities: %w", err)
	}
	for _, id := range ids {
		rid, err := uuid.NewV7()
		if err != nil {
			return fmt.Errorf("accountdelete: 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.DetachedAt, table.RetainedIdentities.Reason,
		).VALUES(rid, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, now, retainDelete)
		if _, err := ins.ExecContext(ctx, tx); err != nil {
			return fmt.Errorf("accountdelete: retain identity %s: %w", id.Kind, err)
		}
	}
	if _, err := table.Identities.DELETE().
		WHERE(table.Identities.AccountID.EQ(postgres.UUID(accountID))).
		ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("accountdelete: delete identities: %w", err)
	}
	return nil
}

// tombstone marks the account deleted, snapshotting the real display name into
// deleted_display_name (evaluated from the old row) before scrubbing the live one.
func tombstone(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, now time.Time) error {
	upd := table.Accounts.UPDATE(
		table.Accounts.DeletedAt, table.Accounts.DeletedDisplayName,
		table.Accounts.DisplayName, table.Accounts.UpdatedAt,
	).SET(
		postgres.TimestampzT(now), table.Accounts.DisplayName,
		postgres.String(AnonymizedName), postgres.TimestampzT(now),
	).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(accountID)))
	if _, err := upd.ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("accountdelete: tombstone account: %w", err)
	}
	return nil
}

// dropSocialAndEphemerals removes the account's own friendships, blocks, invitations
// (as inviter and as invitee), friend codes, drafts and pending confirm-codes. These are
// the deleting user's private data with no dossier value; chat and feedback are kept.
func dropSocialAndEphemerals(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) error {
	id := postgres.UUID(accountID)
	// Friendships and blocks are two-account edges keyed on either endpoint.
	if _, err := table.Friendships.DELETE().
		WHERE(table.Friendships.RequesterID.EQ(id).OR(table.Friendships.AddresseeID.EQ(id))).
		ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("accountdelete: delete friendships: %w", err)
	}
	if _, err := table.Blocks.DELETE().
		WHERE(table.Blocks.BlockerID.EQ(id).OR(table.Blocks.BlockedID.EQ(id))).
		ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("accountdelete: delete blocks: %w", err)
	}
	// Invitations: drop the account's invitee rows, then its own invitations' invitees and
	// the invitations themselves (children first, to respect the foreign key).
	if _, err := table.GameInvitationInvitees.DELETE().
		WHERE(table.GameInvitationInvitees.AccountID.EQ(id)).
		ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("accountdelete: delete invitee rows: %w", err)
	}
	ownInvitations := postgres.SELECT(table.GameInvitations.InvitationID).
		FROM(table.GameInvitations).
		WHERE(table.GameInvitations.InviterID.EQ(id))
	if _, err := table.GameInvitationInvitees.DELETE().
		WHERE(table.GameInvitationInvitees.InvitationID.IN(ownInvitations)).
		ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("accountdelete: delete own invitation invitees: %w", err)
	}
	if _, err := table.GameInvitations.DELETE().
		WHERE(table.GameInvitations.InviterID.EQ(id)).
		ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("accountdelete: delete invitations: %w", err)
	}
	// Ephemerals: friend codes, move drafts, pending confirm-codes.
	if _, err := table.FriendCodes.DELETE().
		WHERE(table.FriendCodes.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("accountdelete: delete friend codes: %w", err)
	}
	if _, err := table.GameDrafts.DELETE().
		WHERE(table.GameDrafts.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("accountdelete: delete drafts: %w", err)
	}
	if _, err := table.EmailConfirmations.DELETE().
		WHERE(table.EmailConfirmations.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("accountdelete: delete confirmations: %w", err)
	}
	return nil
}

// withTx runs fn inside a transaction, committing on success and rolling back on error.
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("accountdelete: begin tx: %w", err)
	}
	if err := fn(tx); err != nil {
		_ = tx.Rollback()
		return err
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("accountdelete: commit tx: %w", err)
	}
	return nil
}
