import React, {
  useCallback,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import { Link, useNavigate, useParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";

import { API_URL, DEFAULT_PAGE_SIZE, VALIDATION } from "../config/api.ts";
import type {
  Dump,
  InviteTreeEntry,
  PaginatedData,
  PublicUser,
  Role,
} from "../model.ts";
import {
  deserializeAuthResponse,
  deserializeDump,
  deserializeInviteTreeEntry,
  deserializePublicUser,
  hydrateDump,
  hydratePlaylist,
  parseAPIResponse,
  type RawDump,
  type RawInviteTreeEntry,
  type RawPublicUser,
  type UpdateUserRequest,
} from "../model.ts";
import { Avatar } from "../components/Avatar.tsx";
import { DumpCard } from "../components/DumpCard.tsx";
import { PlaylistCard } from "../components/PlaylistCard.tsx";
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useTheme } from "../hooks/useTheme.ts";
import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { useFading } from "../hooks/useFading.ts";
import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
import type { Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist } from "../model.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { DumpCreateModal } from "../components/DumpCreateModal.tsx";
import { FollowUserButton } from "../components/FollowButton.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { can } from "../utils/permissions.ts";
import { TextEditor } from "../components/TextEditor.tsx";
import { Markdown } from "../components/Markdown.tsx";
import { ChangePasswordModal } from "../components/ChangePasswordModal.tsx";
import { TabBar } from "../components/TabBar.tsx";
import { useTabParam } from "../hooks/useTabParam.ts";
import { Controller } from "react-hook-form";
import {
  expectOk,
  FormError,
  FormProvider,
  SubmitButton,
  useApiForm,
} from "../components/form/index.ts";

function InviteButton() {
  const { authFetch } = useAuth();
  const [inviteUrl, setInviteUrl] = useState<string | null>(null);
  const [copied, setCopied] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function generate() {
    try {
      const res = await authFetch(`${API_URL}/api/invites`, { method: "POST" });
      const body = await res.json();
      if (body.success) {
        const url =
          `${globalThis.location.origin}/register?token=${body.data.token}`;
        setInviteUrl(url);
      } else {
        setError(t`Failed to generate invite`);
      }
    } catch {
      setError(t`Failed to generate invite`);
    }
  }

  async function copy() {
    if (!inviteUrl) return;
    await navigator.clipboard.writeText(inviteUrl);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  }

  if (inviteUrl) {
    return (
      <div className="invite-result">
        <span className="invite-url">{inviteUrl}</span>
        <button type="button" className="invite-copy-btn" onClick={copy}>
          {copied ? t`Copied!` : t`Copy`}
        </button>
      </div>
    );
  }

  return (
    <div className="invite-generate">
      <button type="button" className="invite-btn" onClick={generate}>
        <Trans>+ Invite someone</Trans>
      </button>
      {error && (
        <ErrorCard title={t`Failed to generate invite`} message={error} />
      )}
    </div>
  );
}

interface PaginatedList<T> {
  items: T[];
  hasMore: boolean;
  page: number;
  loadingMore: boolean;
}

function initialList<T>(items: T[], hasMore: boolean): PaginatedList<T> {
  return { items, hasMore, page: 1, loadingMore: false };
}

type ProfileState =
  | { status: "loading" }
  | { status: "error"; error: string }
  | {
    status: "loaded";
    user: PublicUser;
    dumps: PaginatedList<Dump>;
    votes: PaginatedList<Dump>;
    playlists: PaginatedList<Playlist>;
  };

type FollowedState =
  | null
  | { status: "loading" }
  | { status: "error"; error: string }
  | {
    status: "loaded";
    users: PaginatedList<PublicUser>;
    playlists: PaginatedList<Playlist>;
  };

type InviteTreeState =
  | null
  | { status: "loading" }
  | { status: "error"; error: string }
  | { status: "loaded"; entries: InviteTreeEntry[] };

type InviteTreeNode = InviteTreeEntry & { children: InviteTreeNode[] };
const PROFILE_TABS = [
  "dumps",
  "playlists",
  "followed",
  "invitees",
  "settings",
  "manage",
] as const;
type ProfileTab = (typeof PROFILE_TABS)[number];

function buildInviteTree(
  entries: InviteTreeEntry[],
  parentId: string,
): InviteTreeNode[] {
  return entries
    .filter((e) => e.invitedById === parentId)
    .map((e) => ({ ...e, children: buildInviteTree(entries, e.id) }));
}

export function UserPublicProfile() {
  const { username } = useParams();
  const navigate = useNavigate();
  const { user: me, authFetch, login, logout, token } = useAuth();
  const { style, colorScheme, setStyle, setColorScheme } = useTheme();
  const [defaultFeedTab, setDefaultFeedTab] = useDefaultFeedTab();
  const {
    voteCounts,
    myVotes,
    lastVoteEvent,
    lastDumpEvent,
    lastPlaylistEvent,
    lastUserEvent,
    castVote,
    removeVote,
  } = useWS();

  const { cached: cachedDumps, saveState: saveDumps } = useFeedCache<Dump>(
    `feed:profile-dumps:${username ?? ""}`,
    hydrateDump,
  );
  const { cached: cachedVotes, saveState: saveVotes } = useFeedCache<Dump>(
    `feed:profile-votes:${username ?? ""}`,
    hydrateDump,
  );
  const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<
    Playlist
  >(
    `feed:profile-playlists:${username ?? ""}`,
    hydratePlaylist,
  );

  const [state, setState] = useState<ProfileState>({ status: "loading" });

  useDocumentTitle(
    state.status === "loaded"
      ? state.user.username
      : state.status === "error"
      ? null
      : undefined,
  );

  const profileUserId = state.status === "loaded" ? state.user.id : null;
  const isOwnProfile = me?.id === profileUserId;

  // Active tab is driven by the `/~/<tab>` URL path (linkable / refresh-safe).
  // `settings` is own-profile only; `manage` requires the user:manage permission
  // on someone else's profile. Invalid tabs fall back to "dumps".
  const canManageUsers = can(me, "user:manage");
  const availableTabs: ProfileTab[] = PROFILE_TABS.filter((t) => {
    if (t === "settings") return isOwnProfile;
    if (t === "manage") return !isOwnProfile && canManageUsers;
    return true;
  });
  const [tab, setTab] = useTabParam<ProfileTab>(availableTabs, "dumps");

  const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
    setState((s) =>
      s.status !== "loaded"
        ? s
        : { ...s, dumps: { ...s.dumps, items: fn(s.dumps.items) } }
    );
  }, []);
  const dumpItems = state.status === "loaded" ? state.dumps.items : [];
  usePositionAwareSync(
    dumpItems,
    setDumps,
    lastDumpEvent,
    (d) => d.isPrivate,
    (d) => !d.isPrivate && d.userId === profileUserId,
  );
  useDumpListSync(setDumps, {
    ownerId: profileUserId ?? undefined,
    isOwner: isOwnProfile,
    skipReinsert: true,
  });

  // Dump IDs removed due to vote withdrawal — must not be re-inserted on
  // a future dump_updated event (that would only be for private→public transitions).
  const withdrawnVoteIdsRef = useRef<Set<string>>(new Set());

  const setVotes = useCallback((fn: (prev: Dump[]) => Dump[]) => {
    setState((s) =>
      s.status !== "loaded"
        ? s
        : { ...s, votes: { ...s.votes, items: fn(s.votes.items) } }
    );
  }, []);
  const voteItems = state.status === "loaded" ? state.votes.items : [];
  usePositionAwareSync(
    voteItems,
    setVotes,
    lastDumpEvent,
    (d) => d.isPrivate,
    (d) => !d.isPrivate && !withdrawnVoteIdsRef.current.has(d.id),
  );
  useDumpListSync(setVotes, { skipReinsert: true });

  const setPlaylists = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
    setState((s) =>
      s.status !== "loaded"
        ? s
        : { ...s, playlists: { ...s.playlists, items: fn(s.playlists.items) } }
    );
  }, []);
  const playlistItems = state.status === "loaded" ? state.playlists.items : [];
  const lastPlaylistItem = lastPlaylistEvent?.type === "updated"
    ? (lastPlaylistEvent.playlist ?? null)
    : null;
  usePositionAwareSync(
    playlistItems,
    setPlaylists,
    lastPlaylistItem,
    (p) => !p.isPublic,
    (p) => p.isPublic && p.userId === profileUserId,
  );
  usePlaylistListSync(setPlaylists, {
    isOwner: isOwnProfile,
    ownerId: profileUserId ?? undefined,
    skipReinsert: true,
  });

  // Update profile user when they edit their own profile
  useEffect(() => {
    if (!lastUserEvent) return;
    const { user } = lastUserEvent;
    // Subscribing to the WS-pushed user-update stream — setState here
    // mirrors external state into local state, no synchronous alternative.
    // eslint-disable-next-line react-hooks/set-state-in-effect
    setState((s) => {
      if (s.status !== "loaded" || s.user.id !== user.id) return s;
      return { ...s, user };
    });
  }, [lastUserEvent]);

  const [uploading, setUploading] = useState(false);
  const [avatarError, setAvatarError] = useState<string | null>(null);
  const fileInputRef = useRef<HTMLInputElement>(null);

  const [descEditing, setDescEditing] = useState(false);
  const [emailEditing, setEmailEditing] = useState(false);
  const prevMyVotesRef = useRef<Set<string> | null>(null);

  const [changePasswordOpen, setChangePasswordOpen] = useState(false);
  const [followedState, setFollowedState] = useState<FollowedState>(null);
  const [inviteTreeState, setInviteTreeState] = useState<InviteTreeState>(null);

  const inviteTreeNodes = useMemo(() => {
    if (!profileUserId || inviteTreeState?.status !== "loaded") return [];
    return buildInviteTree(inviteTreeState.entries, profileUserId);
  }, [inviteTreeState, profileUserId]);

  const [prevUsername, setPrevUsername] = useState(username);
  if (prevUsername !== username) {
    setPrevUsername(username);
    setState({ status: "loading" });
    setFollowedState(null);
    setInviteTreeState(null);
  }

  // Refs can't be written during render — reset in an effect instead, which
  // still runs (and clears the ref) before the vote-diffing effect below.
  useEffect(() => {
    prevMyVotesRef.current = null;
  }, [username]);

  useEffect(() => {
    if (!username) return;
    const controller = new AbortController();

    const allCached = cachedDumps && cachedVotes && cachedPlaylists;

    if (allCached) {
      // Only fetch the user object (lightweight, always fresh)
      fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal })
        .then((r) => r.json())
        .then((body) => {
          if (!body.success) throw new Error("User not found");
          const profileUser = deserializePublicUser(body.data);
          setState({
            status: "loaded",
            user: profileUser,
            dumps: {
              items: cachedDumps.items,
              hasMore: cachedDumps.hasMore,
              page: cachedDumps.page,
              loadingMore: false,
            },
            votes: {
              items: cachedVotes.items,
              hasMore: cachedVotes.hasMore,
              page: cachedVotes.page,
              loadingMore: false,
            },
            playlists: {
              items: cachedPlaylists.items,
              hasMore: cachedPlaylists.hasMore,
              page: cachedPlaylists.page,
              loadingMore: false,
            },
          });
        })
        .catch((err) => {
          if (err.name === "AbortError") return;
          setState({ status: "error", error: friendlyFetchError(err) });
        });
      return () => controller.abort();
    }

    (async () => {
      try {
        const authHeaders: HeadersInit = token
          ? { Authorization: `Bearer ${token}` }
          : {};
        const [userRes, dumpsRes, votesRes, playlistsRes] = await Promise.all([
          fetch(`${API_URL}/api/users/${username}`, {
            signal: controller.signal,
          }),
          fetch(
            `${API_URL}/api/users/${username}/dumps?page=1&limit=${DEFAULT_PAGE_SIZE}`,
            { headers: authHeaders, signal: controller.signal },
          ),
          fetch(
            `${API_URL}/api/users/${username}/votes?page=1&limit=${DEFAULT_PAGE_SIZE}`,
            { headers: authHeaders, signal: controller.signal },
          ),
          fetch(
            `${API_URL}/api/users/${username}/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
            { headers: authHeaders, signal: controller.signal },
          ),
        ]);

        if (!userRes.ok) {
          throw new Error(
            userRes.status === 404
              ? "User not found"
              : `HTTP ${userRes.status}`,
          );
        }

        const [userBody, dumpsBody, votesBody, playlistsBody] = await Promise
          .all([
            userRes.json(),
            dumpsRes.json(),
            votesRes.json(),
            playlistsRes.json(),
          ]);

        const votesData: PaginatedData<RawDump> = votesBody.success
          ? votesBody.data
          : { items: [], total: 0, hasMore: false };
        const playlistsData: PaginatedData<RawPlaylist> = playlistsBody.success
          ? playlistsBody.data
          : { items: [], total: 0, hasMore: false };
        const dumpsData: PaginatedData<RawDump> = dumpsBody.success
          ? dumpsBody.data
          : { items: [], total: 0, hasMore: false };

        const profileUser = deserializePublicUser(userBody.data);
        const voteItems = votesData.items.map(deserializeDump);
        setState({
          status: "loaded",
          user: profileUser,
          dumps: initialList(
            dumpsData.items.map(deserializeDump),
            dumpsData.hasMore,
          ),
          votes: initialList(voteItems, votesData.hasMore),
          playlists: initialList(
            playlistsData.items.map(deserializePlaylist),
            playlistsData.hasMore,
          ),
        });
      } catch (err) {
        if ((err as Error).name === "AbortError") return;
        setState({
          status: "error",
          error: friendlyFetchError(err),
        });
      }
    })();
    return () => controller.abort();
  }, [username, cachedDumps, cachedVotes, cachedPlaylists, token]);

  // Own profile: prepend dumps newly voted by the user to the preview list
  useEffect(() => {
    if (!profileUserId || me?.id !== profileUserId) return;
    if (prevMyVotesRef.current === null) {
      prevMyVotesRef.current = new Set(myVotes);
      return;
    }
    const prev = prevMyVotesRef.current;
    setState((s) => {
      if (s.status !== "loaded") return s;
      const voteIds = new Set(s.votes.items.map((d) => d.id));
      const toAdd = s.dumps.items.filter((d) =>
        myVotes.has(d.id) && !prev.has(d.id) && !voteIds.has(d.id)
      );
      if (toAdd.length === 0) return s;
      return {
        ...s,
        votes: { ...s.votes, items: [...toAdd, ...s.votes.items] },
      };
    });
    prevMyVotesRef.current = new Set(myVotes);
  }, [myVotes, me, profileUserId]);

  // Real-time upvoted list sync via WS vote events
  useEffect(() => {
    if (!lastVoteEvent || !profileUserId) return;
    const { dumpId, voterId, action } = lastVoteEvent;
    if (voterId !== profileUserId) return;

    if (action === "remove") {
      // Keep dump in state.votes.items as a ghost — UpvotedDumpList drives
      // its own votedIds + fading state and will animate the removal.
      withdrawnVoteIdsRef.current.add(dumpId);
    } else {
      withdrawnVoteIdsRef.current.delete(dumpId);
      fetch(`${API_URL}/api/dumps/${dumpId}`)
        .then((r) => r.json())
        .then((body) => {
          if (!body.success) return;
          const dump = deserializeDump(body.data);
          setState((s) => {
            if (s.status !== "loaded") return s;
            const idx = s.votes.items.findIndex((d) => d.id === dumpId);
            if (idx !== -1) {
              // Ghost re-voted: update in-place.
              const items = [...s.votes.items];
              items[idx] = dump;
              return { ...s, votes: { ...s.votes, items } };
            }
            // First-time vote: prepend.
            return {
              ...s,
              votes: { ...s.votes, items: [dump, ...s.votes.items] },
            };
          });
        })
        .catch(() => {});
    }
  }, [lastVoteEvent, profileUserId]);

  // Save scroll position + loaded state to sessionStorage on scroll
  useEffect(() => {
    if (state.status !== "loaded") return;
    let timer: ReturnType<typeof setTimeout>;
    const onScroll = () => {
      clearTimeout(timer);
      timer = setTimeout(() => {
        if (state.status !== "loaded") return;
        const y = globalThis.scrollY;
        saveDumps(state.dumps.items, state.dumps.page, state.dumps.hasMore, y);
        saveVotes(state.votes.items, state.votes.page, state.votes.hasMore, y);
        savePlaylists(
          state.playlists.items,
          state.playlists.page,
          state.playlists.hasMore,
          y,
        );
      }, 100);
    };
    globalThis.addEventListener("scroll", onScroll, { passive: true });
    return () => {
      globalThis.removeEventListener("scroll", onScroll);
      clearTimeout(timer);
    };
  }, [state, saveDumps, saveVotes, savePlaylists]);

  // Keep the playlists cache current whenever the list changes (e.g. via WS),
  // so a page refresh restores the up-to-date list rather than a stale snapshot.
  const playlistFeed = state.status === "loaded" ? state.playlists : null;
  useEffect(() => {
    if (!playlistFeed) return;
    savePlaylists(
      playlistFeed.items,
      playlistFeed.page,
      playlistFeed.hasMore,
      globalThis.scrollY,
    );
  }, [playlistFeed, savePlaylists]);

  // Lazy-load followed users + playlists when the Followed tab is first opened
  useEffect(() => {
    if (
      tab !== "followed" || followedState !== null || state.status !== "loaded"
    ) {
      return;
    }
    queueMicrotask(() => setFollowedState({ status: "loading" }));
    const controller = new AbortController();
    Promise.all([
      fetch(
        `${API_URL}/api/users/${username}/followed-users?page=1&limit=${DEFAULT_PAGE_SIZE}`,
        { signal: controller.signal },
      ),
      fetch(
        `${API_URL}/api/users/${username}/followed-playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
        { signal: controller.signal },
      ),
    ])
      .then(([ur, pr]) => Promise.all([ur.json(), pr.json()]))
      .then(([ub, pb]) => {
        const usersData = ub.success
          ? ub.data as PaginatedData<RawPublicUser>
          : { items: [], hasMore: false };
        const playlistsData = pb.success
          ? pb.data as PaginatedData<RawPlaylist>
          : { items: [], hasMore: false };
        setFollowedState({
          status: "loaded",
          users: initialList(
            usersData.items.map(deserializePublicUser),
            usersData.hasMore,
          ),
          playlists: initialList(
            playlistsData.items.map(deserializePlaylist),
            playlistsData.hasMore,
          ),
        });
      })
      .catch((err) => {
        if (err.name === "AbortError") return;
        setFollowedState({ status: "error", error: friendlyFetchError(err) });
      });
    return () => controller.abort();
    // followedState intentionally omitted: it's a read-once guard, not a trigger.
    // Including it would abort the in-flight fetch when state changes to "loading".
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [tab, state.status, username]);

  // Lazy-load invite tree when the Invitees tab is first opened
  useEffect(() => {
    if (
      tab !== "invitees" || inviteTreeState !== null ||
      state.status !== "loaded"
    ) {
      return;
    }
    queueMicrotask(() => setInviteTreeState({ status: "loading" }));
    const controller = new AbortController();
    fetch(
      `${API_URL}/api/users/${username}/invitees`,
      { signal: controller.signal },
    )
      .then((r) => r.json())
      .then((body) => {
        const entries: InviteTreeEntry[] = body.success
          ? (body.data as RawInviteTreeEntry[]).map(deserializeInviteTreeEntry)
          : [];
        setInviteTreeState({ status: "loaded", entries });
      })
      .catch((err) => {
        if (err.name === "AbortError") return;
        setInviteTreeState({ status: "error", error: friendlyFetchError(err) });
      });
    return () => controller.abort();
    // inviteTreeState intentionally omitted — same read-once guard pattern.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [tab, state.status, username]);

  // Restore scroll position after cache restoration
  const scrollRestored = useRef(false);
  useLayoutEffect(() => {
    if (cachedDumps?.scrollY == null || scrollRestored.current) return;
    if (state.status === "loaded") {
      globalThis.scrollTo(0, cachedDumps.scrollY);
      scrollRestored.current = true;
    }
  }, [state.status, cachedDumps]);

  const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file || state.status !== "loaded") return;

    setAvatarError(null);
    setUploading(true);

    try {
      const formData = new FormData();
      formData.append("file", file);

      const res = await authFetch(`${API_URL}/api/avatars/me`, {
        method: "POST",
        body: formData,
      });
      const body = parseAPIResponse<RawPublicUser>(await res.json());

      if (!body.success) {
        setAvatarError(body.error.message);
        return;
      }

      const storedRaw = localStorage.getItem("authResponse");
      if (storedRaw) {
        login({
          ...deserializeAuthResponse(JSON.parse(storedRaw)),
          user: deserializePublicUser(body.data),
        });
      }

      setState((prev) =>
        prev.status === "loaded"
          ? { ...prev, user: deserializePublicUser(body.data) }
          : prev
      );
    } catch {
      setAvatarError(t`Upload failed`);
    } finally {
      setUploading(false);
      if (fileInputRef.current) fileInputRef.current.value = "";
    }
  };

  const handleEmailSaved = (email: string) => {
    const storedRaw = localStorage.getItem("authResponse");
    if (storedRaw) {
      const prev = deserializeAuthResponse(JSON.parse(storedRaw));
      login({ ...prev, user: { ...prev.user, email } });
    }
    setEmailEditing(false);
  };

  const handleDescSaved = (description: string | undefined) => {
    setState((s) =>
      s.status === "loaded"
        ? { ...s, user: { ...s.user, description } }
        : s
    );
    setDescEditing(false);
  };

  const handleRoleSaved = (role: Role) => {
    setState((s) =>
      s.status === "loaded" ? { ...s, user: { ...s.user, role } } : s
    );
  };

  if (state.status === "loading") {
    return (
      <PageShell>
        <p className="page-loading">
          <Trans>Loading profile…</Trans>
        </p>
      </PageShell>
    );
  }

  if (state.status === "error") {
    return (
      <PageError
        message={state.error}
        actions={
          <>
            <button
              className="btn-border"
              type="button"
              onClick={() => navigate("/")}
            >
              <Trans>← Back</Trans>
            </button>
            {me && (
              <button className="btn-border" type="button" onClick={logout}>
                <Trans>Log out</Trans>
              </button>
            )}
          </>
        }
      />
    );
  }

  const { user: profileUser, dumps, votes, playlists } = state;

  return (
    <PageShell>
      <div className="profile-header">
        <div className="profile-avatar-wrapper">
          <Avatar
            userId={profileUser.id}
            username={profileUser.username}
            hasAvatar={!!profileUser.avatarMime}
            size={128}
            version={profileUser.updatedAt?.getTime()}
          />
          {isOwnProfile && (
            <label className="avatar-change-overlay" title={t`Change avatar`}>
              {uploading ? "…" : "✎"}
              <input
                ref={fileInputRef}
                type="file"
                accept="image/jpeg,image/png,image/gif,image/webp"
                onChange={handleAvatarUpload}
                disabled={uploading}
                style={{ display: "none" }}
              />
            </label>
          )}
        </div>
        <div className="profile-info">
          <div className="profile-info-scroll">
            <h1 className="profile-username">{profileUser.username}</h1>
            {profileUser.invitedByUsername
              ? (
                <p className="profile-invited-by">
                  <Trans>invited by</Trans>{" "}
                  <Link
                    to={`/users/${profileUser.invitedByUsername}`}
                    className="profile-invited-by-link"
                  >
                    @{profileUser.invitedByUsername}
                  </Link>
                </p>
              )
              : (
                <p className="profile-invited-by profile-invited-by--founding">
                  O.G.
                </p>
              )}
            {isOwnProfile && (
              emailEditing
                ? (
                  <EmailEditor
                    initialEmail={me?.email ?? ""}
                    onCancel={() => setEmailEditing(false)}
                    onSaved={handleEmailSaved}
                  />
                )
                : (
                  <p
                    className="profile-email-display"
                    onClick={() => setEmailEditing(true)}
                    title="Edit email"
                  >
                    {me?.email ?? t`Add email…`}
                    <span className="profile-description-edit-btn" aria-hidden>
                      ✎
                    </span>
                  </p>
                )
            )}
            {avatarError && (
              <ErrorCard
                title={t`Failed to update avatar`}
                message={avatarError}
              />
            )}
            {!isOwnProfile && (
              <FollowUserButton
                targetUserId={profileUser.id}
                targetUsername={profileUser.username}
              />
            )}
            {isOwnProfile && (
              <div className="profile-own-actions">
                <InviteButton />
                <button type="button" className="btn-border" onClick={logout}>
                  <Trans>Log out</Trans>
                </button>
              </div>
            )}
          </div>
        </div>
      </div>

      {(profileUser.description || isOwnProfile) && (
        <div className="profile-description">
          {descEditing
            ? (
              <DescriptionEditor
                initialDescription={profileUser.description ?? ""}
                onCancel={() => setDescEditing(false)}
                onSaved={handleDescSaved}
              />
            )
            : (
              <div
                className={`profile-description-view${
                  isOwnProfile ? " profile-description-view--editable" : ""
                }`}
                onClick={isOwnProfile
                  ? () => setDescEditing(true)
                  : undefined}
              >
                {profileUser.description
                  ? (
                    <Markdown className="profile-description-text">
                      {profileUser.description}
                    </Markdown>
                  )
                  : (
                    <div className="profile-description-empty">
                      <Trans>Who am I?</Trans>
                    </div>
                  )}
                {isOwnProfile && (
                  <span className="profile-description-edit-btn" aria-hidden>
                    ✎
                  </span>
                )}
              </div>
            )}
        </div>
      )}

      <TabBar
        tabs={[
          { key: "dumps", label: <Trans>Dumps</Trans> },
          { key: "playlists", label: <Trans>Playlists</Trans> },
          { key: "followed", label: <Trans>Followed</Trans> },
          { key: "invitees", label: <Trans>Invitees</Trans> },
          ...(isOwnProfile
            ? [{ key: "settings" as const, label: <Trans>Settings</Trans> }]
            : []),
          ...(!isOwnProfile && canManageUsers
            ? [{ key: "manage" as const, label: <Trans>Manage</Trans> }]
            : []),
        ]}
        activeTab={tab}
        onChange={(key) => setTab(key)}
        className="profile-tabs-scroller"
        innerClassName="profile-tabs"
      />

      {tab === "dumps" && (
        <div className="profile-columns">
          <DumpList
            title={t`Dumps (${dumps.items.length}${dumps.hasMore ? "+" : ""})`}
            dumps={dumps.items}
            voteCounts={voteCounts}
            myVotes={myVotes}
            canVote={!!me}
            castVote={castVote}
            removeVote={removeVote}
            isOwnProfile={isOwnProfile}
            viewAllHref={`/users/${profileUser.username}/dumps`}
          />
          <UpvotedDumpList
            title={t`Upvoted (${votes.items.length}${
              votes.hasMore ? "+" : ""
            })`}
            dumps={votes.items}
            profileUserId={profileUserId}
            isOwnProfile={isOwnProfile}
            voteCounts={voteCounts}
            myVotes={myVotes}
            canVote={!!me}
            castVote={castVote}
            removeVote={removeVote}
            viewAllHref={`/users/${profileUser.username}/upvoted`}
          />
        </div>
      )}

      {tab === "playlists" && (
        <section className="profile-section" id="playlists">
          <div className="profile-section-header">
            <h2 className="profile-section-title">
              <Trans>
                Playlists ({playlists.items.length}
                {playlists.hasMore ? "+" : ""})
              </Trans>
            </h2>
            {isOwnProfile && (
              <NewPlaylistForm
                onCreated={(p) =>
                  setState((s) => {
                    if (s.status !== "loaded") return s;
                    if (
                      s.playlists.items.some((pl) =>
                        pl.id === p.id
                      )
                    ) {
                      return s;
                    }
                    return {
                      ...s,
                      playlists: {
                        ...s.playlists,
                        items: [p, ...s.playlists.items],
                      },
                    };
                  })}
              />
            )}
          </div>
          {playlists.items.length === 0
            ? (
              <p className="empty-state">
                <Trans>No playlists yet.</Trans>
              </p>
            )
            : (
              <ul className="dump-feed">
                {playlists.items.map((p) => (
                  <PlaylistCard
                    key={p.id}
                    playlist={p}
                    isOwner={isOwnProfile}
                  />
                ))}
              </ul>
            )}
          {playlists.items.length > 0 && (
            <Link
              to={`/users/${profileUser.username}/playlists`}
              className="profile-view-all"
            >
              <Trans>View all →</Trans>
            </Link>
          )}
        </section>
      )}

      {tab === "followed" && (
        <div className="profile-columns">
          <section className="profile-section">
            <h2 className="profile-section-title">
              <Trans>Following</Trans>
            </h2>
            {followedState === null || followedState.status === "loading"
              ? (
                <p className="index-status">
                  <Trans>Loading…</Trans>
                </p>
              )
              : followedState.status === "error"
              ? (
                <ErrorCard
                  title={t`Failed to load`}
                  message={followedState.error}
                />
              )
              : followedState.users.items.length === 0
              ? (
                <p className="empty-state">
                  <Trans>Not following anyone yet.</Trans>
                </p>
              )
              : (
                <>
                  <ul className="followed-user-list">
                    {followedState.users.items.map((u) => (
                      <FollowedUserCard key={u.id} user={u} />
                    ))}
                  </ul>
                  {followedState.users.hasMore && (
                    <Link
                      to={`/users/${profileUser.username}/following`}
                      className="profile-view-all"
                    >
                      <Trans>View all →</Trans>
                    </Link>
                  )}
                </>
              )}
          </section>

          <section className="profile-section">
            <h2 className="profile-section-title">
              <Trans>Followed playlists</Trans>
            </h2>
            {followedState === null || followedState.status === "loading"
              ? (
                <p className="index-status">
                  <Trans>Loading…</Trans>
                </p>
              )
              : followedState.status === "error"
              ? (
                <ErrorCard
                  title={t`Failed to load`}
                  message={followedState.error}
                />
              )
              : followedState.playlists.items.length === 0
              ? (
                <p className="empty-state">
                  <Trans>No followed playlists yet.</Trans>
                </p>
              )
              : (
                <>
                  <ul className="dump-feed">
                    {followedState.playlists.items.map((p) => (
                      <PlaylistCard key={p.id} playlist={p} />
                    ))}
                  </ul>
                  {followedState.playlists.hasMore && (
                    <Link
                      to={`/users/${profileUser.username}/playlists`}
                      className="profile-view-all"
                    >
                      <Trans>View all →</Trans>
                    </Link>
                  )}
                </>
              )}
          </section>
        </div>
      )}

      {tab === "invitees" && (
        <section className="profile-section">
          <h2 className="profile-section-title">
            <Trans>Invitees</Trans>
          </h2>
          {inviteTreeState === null || inviteTreeState.status === "loading"
            ? (
              <p className="index-status">
                <Trans>Loading…</Trans>
              </p>
            )
            : inviteTreeState.status === "error"
            ? (
              <ErrorCard
                title={t`Failed to load`}
                message={inviteTreeState.error}
              />
            )
            : inviteTreeState.entries.length === 0
            ? (
              <p className="empty-state invite-tree-empty">
                <Trans>No invitees yet.</Trans>
              </p>
            )
            : <InviteTreeList nodes={inviteTreeNodes} />}
        </section>
      )}

      {changePasswordOpen && (
        <ChangePasswordModal onClose={() => setChangePasswordOpen(false)} />
      )}

      {tab === "settings" && isOwnProfile && (
        <>
          <section className="profile-section">
            <h2 className="profile-section-title">
              <Trans>Account</Trans>
            </h2>
            <div className="profile-appearance-grid">
              <div className="profile-appearance-row">
                <span className="profile-appearance-label">
                  <Trans>Password</Trans>
                </span>
                <button
                  type="button"
                  className="btn btn--ghost"
                  onClick={() => setChangePasswordOpen(true)}
                >
                  <Trans>Change password…</Trans>
                </button>
              </div>
            </div>
          </section>
          <section className="profile-section">
            <h2 className="profile-section-title">
              <Trans>Appearance</Trans>
            </h2>
            <div className="profile-appearance-grid">
              <div className="profile-appearance-row">
                <span className="profile-appearance-label">
                  <Trans>Style</Trans>
                </span>
                <div className="visibility-toggle">
                  <button
                    type="button"
                    className={style === "smooth" ? "active" : ""}
                    onClick={() => setStyle("smooth")}
                  >
                    Smooth
                  </button>
                  <button
                    type="button"
                    className={style === "brutalist" ? "active" : ""}
                    onClick={() => setStyle("brutalist")}
                  >
                    Brutalist
                  </button>
                  <button
                    type="button"
                    className={style === "geocities" ? "active" : ""}
                    onClick={() => setStyle("geocities")}
                  >
                    GeoCities
                  </button>
                  <button
                    type="button"
                    className={style === "nyt" ? "active" : ""}
                    onClick={() => setStyle("nyt")}
                  >
                    Times
                  </button>
                </div>
              </div>
              <div className="profile-appearance-row">
                <span className="profile-appearance-label">
                  <Trans>Color scheme</Trans>
                </span>
                <div className="visibility-toggle">
                  <button
                    type="button"
                    className={colorScheme === "auto" ? "active" : ""}
                    onClick={() => setColorScheme("auto")}
                  >
                    <Trans>Auto</Trans>
                  </button>
                  <button
                    type="button"
                    className={colorScheme === "light" ? "active" : ""}
                    onClick={() => setColorScheme("light")}
                  >
                    <Trans>Light</Trans>
                  </button>
                  <button
                    type="button"
                    className={colorScheme === "dark" ? "active" : ""}
                    onClick={() => setColorScheme("dark")}
                  >
                    <Trans>Dark</Trans>
                  </button>
                </div>
              </div>
            </div>
          </section>
          <section className="profile-section">
            <h2 className="profile-section-title">
              <Trans>Feeds</Trans>
            </h2>
            <div className="profile-appearance-grid">
              <div className="profile-appearance-row">
                <span className="profile-appearance-label">
                  <Trans>Default tab</Trans>
                </span>
                <div className="visibility-toggle">
                  <button
                    type="button"
                    className={defaultFeedTab === "hot" ? "active" : ""}
                    onClick={() => setDefaultFeedTab("hot")}
                  >
                    <Trans>Hot</Trans>
                  </button>
                  <button
                    type="button"
                    className={defaultFeedTab === "new" ? "active" : ""}
                    onClick={() => setDefaultFeedTab("new")}
                  >
                    <Trans>New</Trans>
                  </button>
                  <button
                    type="button"
                    className={defaultFeedTab === "journal" ? "active" : ""}
                    onClick={() => setDefaultFeedTab("journal")}
                  >
                    <Trans>Journal</Trans>
                  </button>
                  <button
                    type="button"
                    className={defaultFeedTab === "followed" ? "active" : ""}
                    onClick={() => setDefaultFeedTab("followed")}
                  >
                    <Trans>Followed</Trans>
                  </button>
                </div>
              </div>
            </div>
          </section>
        </>
      )}

      {tab === "manage" && !isOwnProfile && canManageUsers && (
        <section className="profile-section">
          <h2 className="profile-section-title">
            <Trans>User management</Trans>
          </h2>
          <div className="profile-appearance-grid">
            <RoleSwitcher
              userId={profileUser.id}
              currentRole={profileUser.role}
              onSaved={handleRoleSaved}
            />
          </div>
        </section>
      )}
    </PageShell>
  );
}

// ── Plain dump list ──────────────────────────────────────────────────────────

function DumpList(
  {
    title,
    dumps,
    voteCounts,
    myVotes,
    canVote,
    castVote,
    removeVote,
    isOwnProfile,
    viewAllHref,
  }: {
    title: string;
    dumps: Dump[];
    voteCounts: Record<string, number>;
    myVotes: Set<string>;
    canVote: boolean;
    castVote: (id: string) => void;
    removeVote: (id: string) => void;
    isOwnProfile?: boolean;
    viewAllHref: string;
  },
) {
  const [createModalOpen, setCreateModalOpen] = useState(false);
  return (
    <section className="profile-section">
      <div className="profile-section-header">
        <h2 className="profile-section-title">{title}</h2>
        {isOwnProfile && (
          <button
            type="button"
            className="new-item-toggle"
            onClick={() => setCreateModalOpen(true)}
          >
            +<span className="btn-new-label">
              <Trans>New dump</Trans>
            </span>
          </button>
        )}
      </div>
      {createModalOpen && (
        <DumpCreateModal onClose={() => setCreateModalOpen(false)} />
      )}
      {dumps.length === 0
        ? (
          <p className="empty-state">
            <Trans>Nothing here yet.</Trans>
          </p>
        )
        : (
          <ul className="dump-feed">
            {dumps.map((dump) => (
              <DumpCard
                key={dump.id}
                dump={dump}
                voteCount={voteCounts[dump.id] ?? dump.voteCount}
                voted={myVotes.has(dump.id)}
                canVote={canVote}
                castVote={castVote}
                removeVote={removeVote}
                isOwner={isOwnProfile}
              />
            ))}
          </ul>
        )}
      {dumps.length > 0 && (
        <Link to={viewAllHref} className="profile-view-all">
          <Trans>View all →</Trans>
        </Link>
      )}
    </section>
  );
}

// ── Upvoted list: fades items out when votes are removed ─────────────────────

function UpvotedDumpList(
  {
    title,
    dumps,
    profileUserId,
    isOwnProfile,
    voteCounts,
    myVotes,
    canVote,
    castVote,
    removeVote,
    viewAllHref,
  }: {
    title: string;
    dumps: Dump[];
    profileUserId: string | null;
    isOwnProfile: boolean;
    voteCounts: Record<string, number>;
    myVotes: Set<string>;
    canVote: boolean;
    castVote: (id: string) => void;
    removeVote: (id: string) => void;
    viewAllHref: string;
  },
) {
  const { myVotes: wsMyVotes, lastVoteEvent } = useWS();
  const { fading, startFading, cancelFading } = useFading();

  // votedIds is managed locally so setVotedIds + startFading/cancelFading can
  // be called in the same effect body — guaranteeing a single render where the
  // dump is always in visibleDumps (with or without fading class). This prevents
  // the DOM node from being unmounted/remounted, which would break CSS transitions.
  const [votedIds, setVotedIds] = useState(() =>
    new Set(dumps.map((d) => d.id))
  );
  const prevMyVotesRef = useRef<Set<string> | null>(null);

  // Own profile: sync votedIds with myVotes; start/cancel fading in same batch.
  // setVotedIds and startFading/cancelFading must be called together synchronously
  // in the same effect to guarantee a single render where the DOM node isn't
  // unmounted — converting to render-phase isn't possible because startFading/
  // cancelFading are themselves setState calls that can't run during render.
  useEffect(() => {
    if (!profileUserId || !isOwnProfile) return;
    if (prevMyVotesRef.current === null) {
      // setVotedIds + prevMyVotesRef must be co-located to stay consistent.
      setVotedIds(new Set(wsMyVotes));
      prevMyVotesRef.current = new Set(wsMyVotes);
      return;
    }
    const prev = prevMyVotesRef.current;
    setVotedIds(new Set(wsMyVotes));
    for (const id of prev) if (!wsMyVotes.has(id)) startFading(id);
    for (const id of wsMyVotes) if (!prev.has(id)) cancelFading(id);
    prevMyVotesRef.current = new Set(wsMyVotes);
  }, [wsMyVotes, isOwnProfile, profileUserId, startFading, cancelFading]);

  // Non-own profile: sync votedIds with WS vote events for the profile user.
  // Same constraint as above: setVotedIds and startFading/cancelFading must
  // fire together so the DOM node stays mounted throughout the transition.
  useEffect(() => {
    if (!lastVoteEvent || !profileUserId || isOwnProfile) return;
    const { dumpId, voterId, action } = lastVoteEvent;
    if (voterId !== profileUserId) return;
    if (action === "remove") {
      // setVotedIds and startFading must fire together to avoid a render with
      // stale votedIds between the two updates.
      // eslint-disable-next-line react-hooks/set-state-in-effect
      setVotedIds((prev) => {
        const n = new Set(prev);
        n.delete(dumpId);
        return n;
      });
      startFading(dumpId);
    } else {
      setVotedIds((prev) => new Set([...prev, dumpId]));
      cancelFading(dumpId);
    }
  }, [lastVoteEvent, profileUserId, isOwnProfile, startFading, cancelFading]);

  const visibleDumps = dumps.filter((d) =>
    votedIds.has(d.id) || d.id in fading
  );

  return (
    <section className="profile-section">
      <div className="profile-section-header">
        <h2 className="profile-section-title">{title}</h2>
      </div>
      {visibleDumps.length === 0
        ? (
          <p className="empty-state">
            <Trans>Nothing here yet.</Trans>
          </p>
        )
        : (
          <ul className="dump-feed">
            {visibleDumps.map((dump) => {
              const phase = fading[dump.id];
              const extraCls = phase === "cooldown"
                ? "dump-card--fading"
                : phase === "dismissing"
                ? "dump-card--dismissing"
                : undefined;
              return (
                <DumpCard
                  key={dump.id}
                  dump={dump}
                  voteCount={voteCounts[dump.id] ?? dump.voteCount}
                  voted={myVotes.has(dump.id)}
                  canVote={canVote}
                  castVote={castVote}
                  removeVote={removeVote}
                  isOwner={isOwnProfile}
                  className={extraCls}
                />
              );
            })}
          </ul>
        )}
      {visibleDumps.length > 0 && (
        <Link to={viewAllHref} className="profile-view-all">
          <Trans>View all →</Trans>
        </Link>
      )}
    </section>
  );
}

// ── Invite tree ──────────────────────────────────────────────────────────────

function InviteTreeList({ nodes }: { nodes: InviteTreeNode[] }) {
  return (
    <ul className="invite-tree">
      {nodes.map((node) => (
        <li key={node.id} className="invite-tree-node">
          <Link
            to={`/users/${node.username}`}
            className="invite-tree-user"
          >
            <Avatar
              userId={node.id}
              username={node.username}
              hasAvatar={!!node.avatarMime}
              size={24}
            />
            @{node.username}
          </Link>
          {node.children.length > 0 && <InviteTreeList nodes={node.children} />}
        </li>
      ))}
    </ul>
  );
}

// ── Followed user card ───────────────────────────────────────────────────────

function FollowedUserCard({ user }: { user: PublicUser }) {
  return (
    <li className="followed-user-card">
      <Link to={`/users/${user.username}`} className="followed-user-card-link">
        <Avatar
          userId={user.id}
          username={user.username}
          hasAvatar={!!user.avatarMime}
          size={36}
          version={user.updatedAt?.getTime()}
        />
        <span className="followed-user-card-name">@{user.username}</span>
      </Link>
      <FollowUserButton
        targetUserId={user.id}
        targetUsername={user.username}
      />
    </li>
  );
}

const ROLE_OPTIONS: { value: Role; label: React.ReactNode }[] = [
  { value: "user", label: <Trans>User</Trans> },
  { value: "moderator", label: <Trans>Moderator</Trans> },
  { value: "admin", label: <Trans>Admin</Trans> },
];

interface RoleSwitcherProps {
  userId: string;
  currentRole: Role;
  onSaved: (role: Role) => void;
}

// Segmented role control (matches the dump-visibility toggle style), gated
// behind the user:manage permission by its caller.
function RoleSwitcher({ userId, currentRole, onSaved }: RoleSwitcherProps) {
  const { authFetch } = useAuth();
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function changeRole(role: Role) {
    if (role === currentRole || saving) return;
    setSaving(true);
    setError(null);
    try {
      const res = await authFetch(`${API_URL}/api/users/${userId}/role`, {
        method: "PATCH",
        body: JSON.stringify({ role }),
      });
      const body = parseAPIResponse<RawPublicUser>(await res.json());
      if (!body.success) {
        setError(body.error.message);
        return;
      }
      onSaved(deserializePublicUser(body.data).role);
    } catch (err) {
      setError(friendlyFetchError(err));
    } finally {
      setSaving(false);
    }
  }

  return (
    <>
      <div className="profile-appearance-row">
        <span className="profile-appearance-label">
          <Trans>Role</Trans>
        </span>
        <div className="visibility-toggle">
          {ROLE_OPTIONS.map((opt) => (
            <button
              key={opt.value}
              type="button"
              className={currentRole === opt.value ? "active" : ""}
              disabled={saving}
              onClick={() => changeRole(opt.value)}
            >
              {opt.label}
            </button>
          ))}
        </div>
      </div>
      {error && <ErrorCard title={t`Failed to update role`} message={error} />}
    </>
  );
}

interface EmailEditorProps {
  initialEmail: string;
  onCancel: () => void;
  onSaved: (email: string) => void;
}

function EmailEditor({ initialEmail, onCancel, onSaved }: EmailEditorProps) {
  const { authFetch } = useAuth();
  const form = useApiForm<{ email: string }>({
    defaultValues: { email: initialEmail },
  });
  const email = form.watch("email");
  const submitting = form.formState.isSubmitting;

  const onSubmit = form.submit(async ({ email }) => {
    await expectOk<RawPublicUser>(
      await authFetch(`${API_URL}/api/users/me`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(
          { email: email.trim() } satisfies UpdateUserRequest,
        ),
      }),
    );
    onSaved(email.trim());
  });

  return (
    <FormProvider {...form}>
      <form className="profile-email-editor" onSubmit={onSubmit}>
        <input
          type="email"
          className="profile-email-input"
          {...form.register("email")}
          onKeyDown={(e) => {
            if (e.key === "Escape") onCancel();
          }}
          disabled={submitting}
          autoFocus
        />
        <div className="profile-email-actions">
          <SubmitButton
            className="profile-email-btn profile-email-btn--save"
            pendingLabel={<Trans>Saving…</Trans>}
            disabled={!email.trim()}
          >
            <Trans>Save</Trans>
          </SubmitButton>
          <button
            type="button"
            className="profile-email-btn profile-email-btn--cancel"
            onClick={onCancel}
            disabled={submitting}
          >
            <Trans>Cancel</Trans>
          </button>
        </div>
        <FormError title={t`Failed to save`} />
      </form>
    </FormProvider>
  );
}

interface DescriptionEditorProps {
  initialDescription: string;
  onCancel: () => void;
  onSaved: (description: string | undefined) => void;
}

function DescriptionEditor(
  { initialDescription, onCancel, onSaved }: DescriptionEditorProps,
) {
  const { authFetch } = useAuth();
  const form = useApiForm<{ description: string }>({
    defaultValues: { description: initialDescription },
  });
  const description = form.watch("description");
  const submitting = form.formState.isSubmitting;

  const onSubmit = form.submit(async ({ description }) => {
    const trimmed = description.trim() || undefined;
    await expectOk<RawPublicUser>(
      await authFetch(`${API_URL}/api/users/me`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(
          { description: trimmed } satisfies UpdateUserRequest,
        ),
      }),
    );
    onSaved(trimmed);
  });

  return (
    <FormProvider {...form}>
      <form className="profile-description-editor" onSubmit={onSubmit}>
        <Controller
          name="description"
          control={form.control}
          render={({ field }) => (
            <TextEditor
              className="comment-reply-textarea"
              value={field.value}
              onChange={field.onChange}
              onSubmit={() => void onSubmit()}
              placeholder={t`Who am I?`}
              autoResize
              maxLength={VALIDATION.USER_DESCRIPTION_MAX}
            />
          )}
        />
        <div className="profile-description-actions">
          <SubmitButton
            pendingLabel={<Trans>Saving…</Trans>}
            disabled={description.length > VALIDATION.USER_DESCRIPTION_MAX}
          >
            <Trans>Save</Trans>
          </SubmitButton>
          <button
            type="button"
            className="form-cancel"
            onClick={onCancel}
            disabled={submitting}
          >
            <Trans>Cancel</Trans>
          </button>
          <FormError title={t`Failed to save`} />
        </div>
      </form>
    </FormProvider>
  );
}
