package social

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

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

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

// Friendship statuses persisted in friendships.status.
const (
	friendPending  = "pending"
	friendAccepted = "accepted"
	friendDeclined = "declined"
)

// friendRequestTTL is how long an unanswered (ignored) friend request stays
// pending before it lazily expires and may be re-sent. An explicit decline is
// remembered permanently (status 'declined') instead and is not subject to this
// window; a one-time friend code from the addressee bypasses a decline.
const friendRequestTTL = 30 * 24 * time.Hour

// accountRef resolves accountID into a notify.AccountRef (the display name from the account
// store, empty on a lookup failure), for enriching the friend_* live events so the client
// updates its requests/friends state without a refetch.
func (svc *Service) accountRef(ctx context.Context, accountID uuid.UUID) notify.AccountRef {
	ref := notify.AccountRef{AccountID: accountID.String()}
	if acc, err := svc.accounts.GetByID(ctx, accountID); err == nil {
		ref.DisplayName = acc.DisplayName
	}
	return ref
}

// SendFriendRequest records a pending friend request from requesterID to
// addresseeID — the "befriend an opponent" path. It requires the two to share a
// game (active or finished) and refuses a self-request, a request across a block or
// the addressee's block_friend_requests toggle, a duplicate of a live request or an
// existing friendship, and a re-send after an explicit decline (ErrRequestDeclined).
// An ignored request that has lazily expired (friendRequestTTL) may be re-sent and
// reopens the existing row with a fresh clock.
func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresseeID uuid.UUID) error {
	if requesterID == addresseeID {
		return ErrSelfRelation
	}
	// A guest cannot use friends — friends are a durable-account feature; the UI hides the
	// flow, this is the server source of truth.
	if acc, err := svc.accounts.GetByID(ctx, requesterID); err != nil {
		return err
	} else if acc.IsGuest {
		return ErrGuestForbidden
	}
	iBlockThem, err := svc.store.blockExists(ctx, requesterID, addresseeID)
	if err != nil {
		return err
	}
	theyBlockMe, err := svc.store.blockExists(ctx, addresseeID, requesterID)
	if err != nil {
		return err
	}
	addressee, err := svc.accounts.GetByID(ctx, addresseeID)
	if err != nil {
		if errors.Is(err, account.ErrNotFound) {
			return account.ErrNotFound
		}
		return err
	}
	if addressee.Deleted {
		// The addressee deleted their account: there is nobody to answer the request, so it is
		// refused rather than left pending forever.
		return ErrAccountDeleted
	}
	if iBlockThem {
		// The requester has blocked the addressee — they are the blocker and aware of it.
		return ErrRequestBlocked
	}
	// When the addressee has blocked the requester, the request still proceeds and persists
	// by the normal logic below, but it is never delivered and the blocker never sees it
	// (ListIncomingRequests filters it out) — the blocked requester must not learn of the
	// block. The flag gates only the notification; it also bypasses the addressee's
	// block_friend_requests toggle so the suppressed request looks ordinary to the requester.
	suppressed := theyBlockMe
	if !suppressed && addressee.BlockFriendRequests {
		return ErrRequestBlocked
	}
	shared, err := svc.games.SharedGame(ctx, requesterID, addresseeID)
	if err != nil {
		return err
	}
	if !shared {
		return ErrNoSharedGame
	}
	edges, err := svc.store.loadEdges(ctx, requesterID, addresseeID)
	if err != nil {
		return err
	}
	cutoff := svc.now().Add(-friendRequestTTL)
	for _, e := range edges {
		// Already friends, or the addressee already has a live request awaiting the
		// requester — in both cases there is nothing to (re-)send.
		if e.Status == friendAccepted {
			return ErrRequestExists
		}
		if e.RequesterID == addresseeID && e.Status == friendPending && e.CreatedAt.After(cutoff) {
			return ErrRequestExists
		}
	}
	for _, e := range edges {
		if e.RequesterID != requesterID {
			continue
		}
		switch e.Status {
		case friendDeclined:
			return ErrRequestDeclined
		case friendPending:
			if e.CreatedAt.After(cutoff) {
				return ErrRequestExists
			}
			// An ignored request that has expired — reopen it with a fresh clock.
			if err := svc.store.refreshFriendRequest(ctx, requesterID, addresseeID, svc.now()); err != nil {
				return err
			}
			if !suppressed {
				svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
			}
			return nil
		}
	}
	if err := svc.store.insertFriendRequest(ctx, requesterID, addresseeID, svc.now()); err != nil {
		if isUniqueViolation(err) {
			return ErrRequestExists
		}
		return err
	}
	if !suppressed {
		svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
	}
	return nil
}

// RespondFriendRequest lets addresseeID accept or decline the pending request
// from requesterID. Accepting flips it to a friendship; declining records a
// permanent 'declined' status (so the same requester cannot re-send), rather than
// deleting the row. Either way ErrRequestNotFound is returned when no pending
// request matches.
func (svc *Service) RespondFriendRequest(ctx context.Context, addresseeID, requesterID uuid.UUID, accept bool) error {
	var ok bool
	var err error
	if accept {
		ok, err = svc.store.acceptFriendRequest(ctx, requesterID, addresseeID, svc.now())
	} else {
		ok, err = svc.store.declineFriendRequest(ctx, requesterID, addresseeID, svc.now())
	}
	if err != nil {
		return err
	}
	if !ok {
		return ErrRequestNotFound
	}
	// Tell the original requester their request was answered, so a game screen watching
	// this opponent re-derives its "add to friends" state (accepted -> friends, declined
	// -> stays "request sent").
	if accept {
		svc.pub.Publish(notify.NotificationAccount(requesterID, notify.NotifyFriendAdded, svc.accountRef(ctx, addresseeID)))
	} else {
		svc.pub.Publish(notify.NotificationAccount(requesterID, notify.NotifyFriendDeclined, svc.accountRef(ctx, addresseeID)))
	}
	return nil
}

// CancelFriendRequest withdraws requesterID's own pending request to addresseeID.
func (svc *Service) CancelFriendRequest(ctx context.Context, requesterID, addresseeID uuid.UUID) error {
	ok, err := svc.store.deletePendingRequest(ctx, requesterID, addresseeID)
	if err != nil {
		return err
	}
	if !ok {
		return ErrRequestNotFound
	}
	return nil
}

// Unfriend removes the friendship between the two accounts, in either direction.
// It is idempotent: removing a non-existent friendship is not an error.
func (svc *Service) Unfriend(ctx context.Context, accountID, otherID uuid.UUID) error {
	return svc.store.deleteFriendship(ctx, accountID, otherID)
}

// ListFriends returns the account IDs that are accepted friends of accountID, with any
// the caller has blocked filtered out: a block overrides — but does not delete — the
// friendship, so the blocker stops seeing the blocked friend while the blocked user's own
// list still shows the blocker (they never notice).
func (svc *Service) ListFriends(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
	ids, err := svc.store.listFriends(ctx, accountID)
	if err != nil {
		return nil, err
	}
	return svc.dropBlocked(ctx, accountID, ids)
}

// ListIncomingRequests returns the account IDs that have a live (not yet expired)
// pending friend request awaiting accountID's response, with any from a requester the
// caller has blocked filtered out (their suppressed request stays stored but unseen).
func (svc *Service) ListIncomingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
	ids, err := svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
	if err != nil {
		return nil, err
	}
	return svc.dropBlocked(ctx, accountID, ids)
}

// dropBlocked removes from ids every account the viewer has blocked. It keeps the
// asymmetric block one-directional: the blocker stops seeing those they blocked in their
// friend / incoming-request lists, while the blocked user's lists are never filtered.
func (svc *Service) dropBlocked(ctx context.Context, viewer uuid.UUID, ids []uuid.UUID) ([]uuid.UUID, error) {
	if len(ids) == 0 {
		return ids, nil
	}
	blockedIDs, err := svc.store.listBlocks(ctx, viewer)
	if err != nil {
		return nil, err
	}
	if len(blockedIDs) == 0 {
		return ids, nil
	}
	blocked := make(map[uuid.UUID]struct{}, len(blockedIDs))
	for _, id := range blockedIDs {
		blocked[id] = struct{}{}
	}
	out := make([]uuid.UUID, 0, len(ids))
	for _, id := range ids {
		if _, b := blocked[id]; b {
			continue
		}
		out = append(out, id)
	}
	return out, nil
}

// ListOutgoingRequests returns the account IDs the caller has already requested and
// cannot (re-)request: a live (not yet expired) pending request, or one the addressee
// permanently declined. The game's "add to friends" item reads it to stay disabled
// across reloads (a declined request reads identically to a still-pending one).
func (svc *Service) ListOutgoingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
	return svc.store.listOutgoingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
}

// loadEdges returns every friendship row between a and b in either direction (at
// most one per direction). It feeds SendFriendRequest's re-send classification.
func (s *Store) loadEdges(ctx context.Context, a, b uuid.UUID) ([]model.Friendships, error) {
	stmt := postgres.SELECT(table.Friendships.AllColumns).
		FROM(table.Friendships).
		WHERE(edgeEither(a, b))
	var rows []model.Friendships
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("social: load friendship edges: %w", err)
	}
	return rows, nil
}

// insertFriendRequest inserts a pending request from requester to addressee,
// stamping created_at so the lazy-expiry clock is deterministic under a fake now.
func (s *Store) insertFriendRequest(ctx context.Context, requester, addressee uuid.UUID, now time.Time) error {
	stmt := table.Friendships.INSERT(
		table.Friendships.RequesterID, table.Friendships.AddresseeID, table.Friendships.Status, table.Friendships.CreatedAt,
	).VALUES(requester, addressee, friendPending, now)
	if _, err := stmt.ExecContext(ctx, s.db); err != nil {
		return fmt.Errorf("social: insert friend request: %w", err)
	}
	return nil
}

// acceptFriendRequest flips a pending request to accepted and reports whether a
// row matched.
func (s *Store) acceptFriendRequest(ctx context.Context, requester, addressee uuid.UUID, now time.Time) (bool, error) {
	stmt := table.Friendships.
		UPDATE(table.Friendships.Status, table.Friendships.RespondedAt).
		SET(postgres.String(friendAccepted), postgres.TimestampzT(now)).
		WHERE(
			table.Friendships.RequesterID.EQ(postgres.UUID(requester)).
				AND(table.Friendships.AddresseeID.EQ(postgres.UUID(addressee))).
				AND(table.Friendships.Status.EQ(postgres.String(friendPending))),
		)
	return execAffected(ctx, s.db, stmt, "social: accept friend request")
}

// deletePendingRequest removes a pending request and reports whether a row matched.
// It backs the requester's own cancel (which leaves no trace, unlike a decline).
func (s *Store) deletePendingRequest(ctx context.Context, requester, addressee uuid.UUID) (bool, error) {
	stmt := table.Friendships.DELETE().WHERE(
		table.Friendships.RequesterID.EQ(postgres.UUID(requester)).
			AND(table.Friendships.AddresseeID.EQ(postgres.UUID(addressee))).
			AND(table.Friendships.Status.EQ(postgres.String(friendPending))),
	)
	return execAffected(ctx, s.db, stmt, "social: delete friend request")
}

// declineFriendRequest marks a pending request from requester to addressee as
// permanently declined (so the requester cannot re-send) and reports whether a row
// matched.
func (s *Store) declineFriendRequest(ctx context.Context, requester, addressee uuid.UUID, now time.Time) (bool, error) {
	stmt := table.Friendships.
		UPDATE(table.Friendships.Status, table.Friendships.RespondedAt).
		SET(postgres.String(friendDeclined), postgres.TimestampzT(now)).
		WHERE(
			table.Friendships.RequesterID.EQ(postgres.UUID(requester)).
				AND(table.Friendships.AddresseeID.EQ(postgres.UUID(addressee))).
				AND(table.Friendships.Status.EQ(postgres.String(friendPending))),
		)
	return execAffected(ctx, s.db, stmt, "social: decline friend request")
}

// refreshFriendRequest resets an expired pending request's created_at so it counts
// as freshly sent again.
func (s *Store) refreshFriendRequest(ctx context.Context, requester, addressee uuid.UUID, now time.Time) error {
	stmt := table.Friendships.
		UPDATE(table.Friendships.CreatedAt).
		SET(postgres.TimestampzT(now)).
		WHERE(
			table.Friendships.RequesterID.EQ(postgres.UUID(requester)).
				AND(table.Friendships.AddresseeID.EQ(postgres.UUID(addressee))).
				AND(table.Friendships.Status.EQ(postgres.String(friendPending))),
		)
	if _, err := stmt.ExecContext(ctx, s.db); err != nil {
		return fmt.Errorf("social: refresh friend request: %w", err)
	}
	return nil
}

// deleteFriendship removes an accepted friendship in either direction.
func (s *Store) deleteFriendship(ctx context.Context, a, b uuid.UUID) error {
	stmt := table.Friendships.DELETE().WHERE(
		edgeEither(a, b).AND(table.Friendships.Status.EQ(postgres.String(friendAccepted))),
	)
	if _, err := stmt.ExecContext(ctx, s.db); err != nil {
		return fmt.Errorf("social: delete friendship: %w", err)
	}
	return nil
}

// listFriends returns the other side of every accepted edge touching accountID.
func (s *Store) listFriends(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
	stmt := postgres.SELECT(table.Friendships.RequesterID, table.Friendships.AddresseeID).
		FROM(table.Friendships).
		WHERE(
			table.Friendships.Status.EQ(postgres.String(friendAccepted)).
				AND(table.Friendships.RequesterID.EQ(postgres.UUID(accountID)).
					OR(table.Friendships.AddresseeID.EQ(postgres.UUID(accountID)))),
		)
	var rows []model.Friendships
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("social: list friends: %w", err)
	}
	out := make([]uuid.UUID, 0, len(rows))
	for _, r := range rows {
		if r.RequesterID == accountID {
			out = append(out, r.AddresseeID)
		} else {
			out = append(out, r.RequesterID)
		}
	}
	return out, nil
}

// listIncomingRequests returns the requesters of every live (created after cutoff)
// pending request to accountID; lazily expired requests are hidden.
func (s *Store) listIncomingRequests(ctx context.Context, accountID uuid.UUID, cutoff time.Time) ([]uuid.UUID, error) {
	stmt := postgres.SELECT(table.Friendships.RequesterID).
		FROM(table.Friendships).
		WHERE(
			table.Friendships.AddresseeID.EQ(postgres.UUID(accountID)).
				AND(table.Friendships.Status.EQ(postgres.String(friendPending))).
				AND(table.Friendships.CreatedAt.GT(postgres.TimestampzT(cutoff))),
		)
	var rows []model.Friendships
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("social: list incoming requests: %w", err)
	}
	out := make([]uuid.UUID, 0, len(rows))
	for _, r := range rows {
		out = append(out, r.RequesterID)
	}
	return out, nil
}

// listOutgoingRequests returns the addressees of the caller's requests that block a
// re-send: a live (created after cutoff) pending request, or a permanently declined
// one. An ignored pending request that has lazily expired is omitted (it may be re-sent).
func (s *Store) listOutgoingRequests(ctx context.Context, accountID uuid.UUID, cutoff time.Time) ([]uuid.UUID, error) {
	stmt := postgres.SELECT(table.Friendships.AddresseeID).
		FROM(table.Friendships).
		WHERE(
			table.Friendships.RequesterID.EQ(postgres.UUID(accountID)).
				AND(table.Friendships.Status.EQ(postgres.String(friendDeclined)).
					OR(table.Friendships.Status.EQ(postgres.String(friendPending)).
						AND(table.Friendships.CreatedAt.GT(postgres.TimestampzT(cutoff))))),
		)
	var rows []model.Friendships
	if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
		return nil, fmt.Errorf("social: list outgoing requests: %w", err)
	}
	out := make([]uuid.UUID, 0, len(rows))
	for _, r := range rows {
		out = append(out, r.AddresseeID)
	}
	return out, nil
}

// edgeEither matches a friendship row between a and b in either direction.
func edgeEither(a, b uuid.UUID) postgres.BoolExpression {
	return table.Friendships.RequesterID.EQ(postgres.UUID(a)).AND(table.Friendships.AddresseeID.EQ(postgres.UUID(b))).
		OR(table.Friendships.RequesterID.EQ(postgres.UUID(b)).AND(table.Friendships.AddresseeID.EQ(postgres.UUID(a))))
}

// execAffected runs a mutating statement and reports whether it changed a row.
func execAffected(ctx context.Context, db qrm.Executable, stmt postgres.Statement, what string) (bool, error) {
	res, err := stmt.ExecContext(ctx, db)
	if err != nil {
		return false, fmt.Errorf("%s: %w", what, err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return false, fmt.Errorf("%s rows: %w", what, err)
	}
	return n > 0, nil
}
