import {
  useCallback,
  useEffect,
  useLayoutEffect,
  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, VALIDATION } from "../config/api.ts";
import { CountedInput } from "../components/CountedInput.tsx";
import type {
  Playlist,
  PlaylistWithDumps,
  RawDump,
  RawPlaylist,
  RawPlaylistWithDumps,
  ReorderPlaylistRequest,
  UpdatePlaylistRequest,
} from "../model.ts";
import {
  deserializeDump,
  deserializePlaylist,
  deserializePlaylistWithDumps,
} from "../model.ts";
import { playlistUrl } from "../utils/urls.ts";
import { can } from "../utils/permissions.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useWS } from "../hooks/useWS.ts";
import { relativeTime } from "../utils/relativeTime.ts";
import { DumpCard } from "../components/DumpCard.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import { ConfirmModal } from "../components/ConfirmModal.tsx";
import { ImagePicker } from "../components/ImagePicker.tsx";
import { Markdown } from "../components/Markdown.tsx";
import { TextEditor } from "../components/TextEditor.tsx";
import { FollowPlaylistButton } from "../components/FollowButton.tsx";
import { Tooltip } from "../components/Tooltip.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { Controller } from "react-hook-form";
import {
  expectOk,
  FormError,
  FormProvider,
  SubmitButton,
  useApiForm,
} from "../components/form/index.ts";

type LoadState =
  | { status: "loading" }
  | { status: "error"; error: string }
  | { status: "loaded"; playlist: PlaylistWithDumps };

export function PlaylistDetail() {
  const { playlistId } = useParams<{ playlistId: string }>();
  const navigate = useNavigate();
  const { user, authFetch, token } = useAuth();
  const {
    voteCounts,
    myVotes,
    castVote,
    removeVote,
    deletedDumpIds,
    lastDumpEvent,
    lastPlaylistEvent,
  } = useWS();

  const [state, setState] = useState<LoadState>({ status: "loading" });
  // Stable UUID for WS comparisons — avoids re-running effects on every state change
  const playlistUUID = state.status === "loaded" ? state.playlist.id : null;

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

  // activeDumpIds: which dumps are currently in the playlist (the canonical set)
  const [activeDumpIds, setActiveDumpIds] = useState<Set<string>>(new Set());

  // fading: dumps whose removal is being animated — same cooldown→dismissing pattern as upvoted list
  const [fading, setFading] = useState<
    Record<string, "cooldown" | "dismissing">
  >({});
  const cancels = useRef<Map<string, () => void>>(new Map());
  // While an undo-remove is in flight (POST re-add + PUT reorder), holds the
  // desired dump order so intermediate WS dumps_updated events don't cause glitches.
  const pendingUndoOrderRef = useRef<string[] | null>(null);
  // Debounce timer for the reorder setState in dumps_updated so that rapid
  // consecutive events (POST re-add followed immediately by PUT reorder) are
  // coalesced — only the final order is applied, preventing the glitch on
  // other clients who don't have pendingUndoOrderRef.
  const dumpReorderTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
    null,
  );

  // dragSrcRef: mutable ref so handleDragOver always sees the current source index
  // without stale closure issues (state would only update on next render).
  const dragSrcRef = useRef<number | null>(null);
  const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);

  const [editOpen, setEditOpen] = useState(false);
  const [confirmDelete, setConfirmDelete] = useState(false);

  // Mirrors activeDumpIds for use in effects without adding it as a dep.
  // Updated on every render via useLayoutEffect so it's always current.
  const activeDumpIdsRef = useRef(activeDumpIds);
  // knownDumpIds: all dump IDs that belong to this playlist (for re-adding when dumps become public again)
  const knownDumpIdsRef = useRef<Set<string>>(new Set());
  // Authoritative dump order from the server (fetchPlaylist + dumps_updated events).
  // Used to re-insert dumps at their correct position after private→public transitions.
  const dumpOrderRef = useRef<string[]>([]);

  useLayoutEffect(() => {
    activeDumpIdsRef.current = activeDumpIds;
  });

  useEffect(() => () => {
    cancels.current.forEach((c) => c());
    if (dumpReorderTimerRef.current) clearTimeout(dumpReorderTimerRef.current);
  }, []);

  const fetchAbortRef = useRef<AbortController | null>(null);

  const fetchPlaylist = useCallback(() => {
    if (!playlistId) return;
    fetchAbortRef.current?.abort();
    const controller = new AbortController();
    fetchAbortRef.current = controller;
    // Deferred to a microtask so callers invoking this directly from an
    // effect body don't trip the synchronous-setState-in-effect check —
    // same timing for the user, just not in the same tick.
    queueMicrotask(() => setState({ status: "loading" }));
    fetch(`${API_URL}/api/playlists/${playlistId}`, {
      signal: controller.signal,
      headers: token ? { Authorization: `Bearer ${token}` } : {},
    })
      .then((r) => {
        if (!r.ok) {
          throw new Error(
            r.status === 404 ? "Playlist not found" : `HTTP ${r.status}`,
          );
        }
        return r.json();
      })
      .then((body) => {
        if (!body.success) throw new Error("Failed to load playlist");
        const pl = deserializePlaylistWithDumps(
          body.data as RawPlaylistWithDumps,
        );
        setState({ status: "loaded", playlist: pl });
        const ids = new Set(pl.dumps.map((d) => d.id));
        const order = pl.dumps.map((d) => d.id);
        setActiveDumpIds(ids);
        dumpOrderRef.current = order;
        for (const id of ids) knownDumpIdsRef.current.add(id);
        setFading({});
        cancels.current.forEach((c) => c());
        cancels.current.clear();
        if (dumpReorderTimerRef.current) {
          clearTimeout(dumpReorderTimerRef.current);
          dumpReorderTimerRef.current = null;
        }
      })
      .catch((err) => {
        if (err.name === "AbortError") return;
        setState({
          status: "error",
          error: friendlyFetchError(err),
        });
      });
  }, [playlistId, token]);

  useEffect(() => {
    fetchPlaylist();
    return () => fetchAbortRef.current?.abort();
  }, [fetchPlaylist]);

  // Start the cooldown→dismissing→gone sequence for a dump being removed.
  // After the sequence completes, the dump is removed from state.playlist.dumps.
  function startFade(id: string) {
    if (cancels.current.has(id)) return; // already fading
    let dead = false;
    let kill = () => {};

    kill = () => {
      dead = true;
      setFading((f) => {
        const n = { ...f };
        delete n[id];
        return n;
      });
      cancels.current.delete(id);
    };
    cancels.current.set(id, () => kill());
    setFading((f) => ({ ...f, [id]: "cooldown" }));

    const t1 = setTimeout(() => {
      if (dead) return;
      setFading((f) => ({ ...f, [id]: "dismissing" }));

      const t2 = setTimeout(() => {
        if (dead) return;
        // Remove from the playlist.dumps array now that animation is done
        setState((prev) => {
          if (prev.status !== "loaded") return prev;
          return {
            ...prev,
            playlist: {
              ...prev.playlist,
              dumps: prev.playlist.dumps.filter((d) => d.id !== id),
            },
          };
        });
        kill();
      }, 350);

      kill = () => {
        dead = true;
        clearTimeout(t2);
        setFading((f) => {
          const n = { ...f };
          delete n[id];
          return n;
        });
        cancels.current.delete(id);
      };
      cancels.current.set(id, () => kill());
    }, 2000);

    kill = () => {
      dead = true;
      clearTimeout(t1);
      setFading((f) => {
        const n = { ...f };
        delete n[id];
        return n;
      });
      cancels.current.delete(id);
    };
    cancels.current.set(id, () => kill());
  }

  // WS: playlist metadata updated or deleted
  useEffect(() => {
    if (!lastPlaylistEvent || !playlistUUID) return;
    const ev = lastPlaylistEvent;
    // Compare against the resolved UUID, not the URL param (which may be a slug)
    if (ev.playlistId !== playlistUUID) return;

    if (ev.type === "dumps_updated" && ev.dumpIds) {
      const newIds = new Set(ev.dumpIds);
      for (const id of newIds) knownDumpIdsRef.current.add(id);
      // Use the ref so we always diff against the current activeDumpIds,
      // including changes from deletedDumpIds / lastDumpEvent effects.
      const prev = activeDumpIdsRef.current;

      // Removed: were active, not in new set → fade out
      for (const id of prev) {
        if (!newIds.has(id)) {
          setActiveDumpIds((s) => {
            const n = new Set(s);
            n.delete(id);
            return n;
          });
          startFade(id);
        }
      }

      // Newly added IDs: cancel any fade, mark active, fetch dump data individually.
      // We never call fetchPlaylist here — that would reset state to "loading", cycle
      // playlistUUID, and re-trigger this effect in a loop.
      for (const id of newIds) {
        if (!prev.has(id)) {
          cancels.current.get(id)?.();
          setActiveDumpIds((s) => new Set([...s, id]));
          // Capture ev.dumpIds so we can insert the new dump at its correct position.
          const orderedIds = ev.dumpIds!;
          fetch(`${API_URL}/api/dumps/${id}`, {
            headers: token ? { Authorization: `Bearer ${token}` } : {},
          })
            .then((r) => r.ok ? r.json() : null)
            .then((body) => {
              if (!body?.success) return;
              const dump = deserializeDump(body.data as RawDump);
              setState((s) => {
                if (s.status !== "loaded") return s;
                if (s.playlist.dumps.some((d) => d.id === dump.id)) return s;
                // Insert at the correct server-ordered position.
                const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d]));
                dumpMap.set(dump.id, dump);
                return {
                  ...s,
                  playlist: {
                    ...s.playlist,
                    dumps: [
                      ...orderedIds
                        .filter((oid) => dumpMap.has(oid))
                        .map((oid) => dumpMap.get(oid)!),
                      ...s.playlist.dumps.filter((d) => !newIds.has(d.id)),
                    ],
                  },
                };
              });
            })
            .catch(() => {});
        }
      }

      // Debounce the reorder setState so rapid consecutive dumps_updated events
      // (e.g. POST re-add followed immediately by PUT reorder during an undo)
      // coalesce into a single update — only the final order is applied.
      // On the owner's client, pendingUndoOrderRef also suppresses the wrong
      // intermediate order; this debounce protects other clients on the WS.
      if (dumpReorderTimerRef.current) {
        clearTimeout(dumpReorderTimerRef.current);
      }
      const orderToApply = pendingUndoOrderRef.current ?? ev.dumpIds!;
      const serverOrder = ev.dumpIds!;
      dumpReorderTimerRef.current = setTimeout(() => {
        dumpReorderTimerRef.current = null;
        setState((s) => {
          if (s.status !== "loaded") return s;
          const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d]));
          const orderedActive = orderToApply
            .filter((id) => dumpMap.has(id))
            .map((id) => dumpMap.get(id)!);
          let ai = 0;
          // Replace each active slot with the next server-ordered active dump;
          // fading dumps keep their current slot unchanged.
          const merged = s.playlist.dumps.map((d) =>
            newIds.has(d.id) ? orderedActive[ai++] : d
          );
          // Append any newly added dumps not yet in the array.
          while (ai < orderedActive.length) merged.push(orderedActive[ai++]);
          return { ...s, playlist: { ...s.playlist, dumps: merged } };
        });
        dumpOrderRef.current = serverOrder;
      }, 80);
    } else if (ev.type === "updated" && ev.playlist) {
      setState((prev) => {
        if (prev.status !== "loaded") return prev;
        return {
          ...prev,
          playlist: {
            ...prev.playlist,
            title: ev.playlist!.title,
            description: ev.playlist!.description,
            isPublic: ev.playlist!.isPublic,
            imageMime: ev.playlist!.imageMime,
          },
        };
      });
    } else if (ev.type === "deleted") {
      navigate("/");
    }
  }, [lastPlaylistEvent, playlistUUID, navigate, token]);

  // Filter out globally deleted dumps (dump was deleted entirely, not just removed from playlist)
  useEffect(() => {
    if (deletedDumpIds.size === 0) return;
    // Subscribing to the WS-pushed deleted-dump-ids set — setState here
    // mirrors external state into local state, no synchronous alternative.
    // eslint-disable-next-line react-hooks/set-state-in-effect
    setState((prev) => {
      if (prev.status !== "loaded") return prev;
      const filtered = prev.playlist.dumps.filter((d) =>
        !deletedDumpIds.has(d.id)
      );
      if (filtered.length === prev.playlist.dumps.length) return prev;
      return { ...prev, playlist: { ...prev.playlist, dumps: filtered } };
    });
    setActiveDumpIds((prev) => {
      const n = new Set(prev);
      for (const id of deletedDumpIds) n.delete(id);
      return n;
    });
  }, [deletedDumpIds]);

  // Update dump metadata in-place; re-add if it was in this playlist but hidden (private→public)
  useEffect(() => {
    if (!lastDumpEvent) return;
    const dump = lastDumpEvent;
    setState((prev) => {
      if (prev.status !== "loaded") return prev;
      const idx = prev.playlist.dumps.findIndex((d) => d.id === dump.id);
      if (idx !== -1) {
        // Update in-place
        const dumps = [...prev.playlist.dumps];
        dumps[idx] = dump;
        return { ...prev, playlist: { ...prev.playlist, dumps } };
      }
      // Re-add if this dump belongs to the playlist and is now public,
      // inserting at its correct server-ordered position.
      if (!dump.isPrivate && knownDumpIdsRef.current.has(dump.id)) {
        const order = dumpOrderRef.current;
        const dumpMap = new Map(prev.playlist.dumps.map((d) => [d.id, d]));
        dumpMap.set(dump.id, dump);
        const reinserted = order.length > 0
          ? [
            ...order.filter((id) => dumpMap.has(id)).map((id) =>
              dumpMap.get(id)!
            ),
            ...prev.playlist.dumps.filter((d) => !new Set(order).has(d.id)),
          ]
          : [...prev.playlist.dumps, dump];
        return { ...prev, playlist: { ...prev.playlist, dumps: reinserted } };
      }
      return prev;
    });
    // Restore to activeDumpIds if re-added
    if (!dump.isPrivate && knownDumpIdsRef.current.has(dump.id)) {
      setActiveDumpIds((prev) => {
        if (prev.has(dump.id)) return prev;
        return new Set([...prev, dump.id]);
      });
    }
  }, [lastDumpEvent]);

  const handleDragStart = (index: number) => {
    dragSrcRef.current = index;
  };

  const handleDragOver = (e: React.DragEvent, index: number) => {
    e.preventDefault();
    const src = dragSrcRef.current;
    if (src === null || src === index) return;
    // Only swap once the pointer has crossed the card's midpoint.
    // Without this, entering a card immediately re-triggers the swap in the
    // opposite direction (the two items keep bouncing back and forth).
    const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
    const mid = rect.top + rect.height / 2;
    if (src < index && e.clientY < mid) return; // dragging downward, not past mid yet
    if (src > index && e.clientY > mid) return; // dragging upward, not past mid yet
    // Update visual order in state. Use activeDumpIdsRef so the updater never
    // reads a stale closure — activeDumpIds can't change mid-drag but this is
    // the correct pattern for setState updaters.
    setState((prev) => {
      if (prev.status !== "loaded") return prev;
      const ids = activeDumpIdsRef.current;
      const activeDumps = prev.playlist.dumps.filter((d) => ids.has(d.id));
      const fadingDumps = prev.playlist.dumps.filter((d) => !ids.has(d.id));
      const reordered = [...activeDumps];
      const [moved] = reordered.splice(src, 1);
      reordered.splice(index, 0, moved);
      return {
        ...prev,
        playlist: { ...prev.playlist, dumps: [...reordered, ...fadingDumps] },
      };
    });
    // Update the ref and highlight index outside the updater (no side effects inside updaters).
    dragSrcRef.current = index;
    setDragOverIndex(index);
  };

  const handleDragEnd = async () => {
    const src = dragSrcRef.current;
    dragSrcRef.current = null;
    setDragOverIndex(null);
    if (src === null || state.status !== "loaded" || !playlistId) return;
    const activeDumps = state.playlist.dumps.filter((d) =>
      activeDumpIds.has(d.id)
    );
    try {
      await authFetch(`${API_URL}/api/playlists/${playlistId}/order`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(
          {
            dumpIds: activeDumps.map((d) => d.id),
          } satisfies ReorderPlaylistRequest,
        ),
      });
    } catch {
      fetchPlaylist();
    }
  };

  const handleRemoveDump = (dumpId: string) => {
    if (!playlistId) return;
    // Fire-and-forget the API call; animate immediately
    authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
      method: "DELETE",
    }).catch(() => {
      // On failure, cancel the fade and restore the item
      cancels.current.get(dumpId)?.();
      setActiveDumpIds((prev) => new Set([...prev, dumpId]));
    });
    setActiveDumpIds((prev) => {
      const n = new Set(prev);
      n.delete(dumpId);
      return n;
    });
    startFade(dumpId);
  };

  const handleCancelRemove = (dumpId: string) => {
    if (!playlistId || state.status !== "loaded") return;
    cancels.current.get(dumpId)?.();
    setActiveDumpIds((prev) => new Set([...prev, dumpId]));
    // Capture the desired order now (dump is still in playlist.dumps at its
    // original position; activeDumpIds hasn't been updated yet in this closure).
    const restoredIds = new Set([...activeDumpIds, dumpId]);
    const desiredOrder = state.playlist.dumps
      .filter((d) => restoredIds.has(d.id))
      .map((d) => d.id);
    // Hold the desired order so the WS handler ignores the intermediate
    // dumps_updated event from the POST (which puts the dump at the top).
    pendingUndoOrderRef.current = desiredOrder;
    // Re-add server-side since DELETE already fired, then immediately restore
    // the original position (addDumpToPlaylist would otherwise put it at top).
    authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
      method: "POST",
    })
      .then(() =>
        authFetch(`${API_URL}/api/playlists/${playlistId}/order`, {
          method: "PUT",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(
            { dumpIds: desiredOrder } satisfies ReorderPlaylistRequest,
          ),
        })
      )
      .finally(() => {
        pendingUndoOrderRef.current = null;
      })
      .catch(() => {});
  };

  const openEdit = () => setEditOpen(true);

  const handleEditSaved = (updated: Playlist) => {
    setEditOpen(false);
    navigate(playlistUrl(updated), { replace: true });
  };

  const handleDelete = async () => {
    if (!playlistId) return;
    await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
      method: "DELETE",
    });
    navigate("/");
  };

  if (state.status === "loading") {
    return (
      <PageShell>
        <p className="page-loading">
          <Trans>Loading playlist…</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>
        }
      />
    );
  }

  const { playlist } = state;
  const isOwner = !!user && user.id === playlist.userId;
  // Owners edit their own playlists; moderators may edit any (playlist:moderate).
  const canEdit = isOwner || can(user, "playlist:moderate");

  // Active dumps in playlist order; fading dumps appended so they stay visible
  const activeDumps = playlist.dumps.filter((d) => activeDumpIds.has(d.id));
  const visibleDumps = playlist.dumps.filter((d) =>
    activeDumpIds.has(d.id) || d.id in fading
  );

  return (
    <PageShell>
      <div className="playlist-detail-header">
        <div className="playlist-detail-header-top">
          {editOpen
            ? (
              <PlaylistEditForm
                playlist={playlist}
                onCancel={() => setEditOpen(false)}
                onRequestDelete={() => setConfirmDelete(true)}
                onSaved={handleEditSaved}
              />
            )
            : (
              <>
                {playlist.imageMime && (
                  <img
                    src={`${API_URL}/api/playlists/${playlist.id}/image`}
                    alt=""
                    className="playlist-detail-img"
                  />
                )}
                <div className="playlist-detail-content">
                  <div className="playlist-detail-title-row">
                    <h1 className="playlist-detail-title">{playlist.title}</h1>
                    {!isOwner && (
                      <FollowPlaylistButton
                        targetPlaylistId={playlist.id}
                        isPublic={playlist.isPublic}
                      />
                    )}
                    {canEdit && (
                      <button
                        type="button"
                        className="playlist-edit-btn"
                        onClick={openEdit}
                      >
                        <Trans>Edit</Trans>
                      </button>
                    )}
                  </div>

                  {playlist.description && (
                    <Markdown className="playlist-detail-description">
                      {playlist.description}
                    </Markdown>
                  )}

                  <div className="playlist-detail-meta">
                    <span
                      className={`playlist-badge${
                        playlist.isPublic ? "" : " playlist-badge--private"
                      }`}
                    >
                      {playlist.isPublic
                        ? <Trans>public</Trans>
                        : <Trans>private</Trans>}
                    </span>
                    {playlist.ownerUsername && (
                      <Link
                        to={`/users/${playlist.ownerUsername}`}
                        className="playlist-detail-owner"
                      >
                        @{playlist.ownerUsername}
                      </Link>
                    )}
                    <Tooltip text={playlist.createdAt.toLocaleString()}>
                      <time dateTime={playlist.createdAt.toISOString()}>
                        {relativeTime(playlist.createdAt)}
                      </time>
                    </Tooltip>
                    {playlist.updatedAt && (
                      <Tooltip
                        text={t`Edited ${playlist.updatedAt.toLocaleString()}`}
                      >
                        <span className="playlist-edited-label">
                          <Trans>
                            edited {relativeTime(playlist.updatedAt)}
                          </Trans>
                        </span>
                      </Tooltip>
                    )}
                  </div>
                </div>
              </>
            )}
        </div>
      </div>

      {visibleDumps.length === 0
        ? (
          <p className="empty-state">
            <Trans>No dumps in this playlist yet.</Trans>
          </p>
        )
        : (
          <div
            className="playlist-dump-list"
            onDragOver={canEdit ? (e) => e.preventDefault() : undefined}
          >
            {visibleDumps.map((dump) => {
              const isActive = activeDumpIds.has(dump.id);
              const phase = fading[dump.id];
              // drag index is within the active-only list
              const activeIndex = isActive ? activeDumps.indexOf(dump) : -1;
              const cardCls = phase === "cooldown"
                ? "dump-card--fading"
                : phase === "dismissing"
                ? "dump-card--dismissing"
                : undefined;

              return (
                <div
                  key={dump.id}
                  className={`playlist-dump-item${
                    activeIndex === dragOverIndex && isActive
                      ? " playlist-dump-item--drag-over"
                      : ""
                  }`}
                  draggable={canEdit && isActive}
                  onDragStart={canEdit && isActive
                    ? () => handleDragStart(activeIndex)
                    : undefined}
                  onDragOver={canEdit && isActive
                    ? (e) => handleDragOver(e, activeIndex)
                    : undefined}
                  onDragEnd={canEdit ? handleDragEnd : undefined}
                >
                  {canEdit && isActive && (
                    <span className="drag-handle" aria-hidden>⠿</span>
                  )}
                  <DumpCard
                    dump={dump}
                    voteCount={voteCounts[dump.id] ?? dump.voteCount}
                    voted={myVotes.has(dump.id)}
                    canVote={!!user}
                    castVote={castVote}
                    removeVote={removeVote}
                    className={cardCls}
                    isOwner={!!user && user.id === dump.userId}
                  />
                  {canEdit && (isActive
                    ? (
                      <button
                        type="button"
                        className="playlist-remove-btn"
                        onClick={() => handleRemoveDump(dump.id)}
                        aria-label={t`Remove from playlist`}
                      >
                        ✕
                      </button>
                    )
                    : phase === "cooldown" && (
                      <button
                        type="button"
                        className="playlist-cancel-btn"
                        onClick={() => handleCancelRemove(dump.id)}
                        aria-label={t`Cancel removal`}
                      >
                        <Trans>Undo</Trans>
                      </button>
                    ))}
                </div>
              );
            })}
          </div>
        )}
      {confirmDelete && (
        <ConfirmModal
          message={t`Delete this playlist? This cannot be undone.`}
          confirmLabel={t`Delete playlist`}
          onConfirm={handleDelete}
          onCancel={() => setConfirmDelete(false)}
        />
      )}
    </PageShell>
  );
}

interface PlaylistEditFormProps {
  playlist: PlaylistWithDumps;
  onCancel: () => void;
  onRequestDelete: () => void;
  onSaved: (playlist: Playlist) => void;
}

interface EditValues {
  title: string;
  description: string;
  isPublic: boolean;
  image: File | null;
}

function PlaylistEditForm(
  { playlist, onCancel, onRequestDelete, onSaved }: PlaylistEditFormProps,
) {
  const { authFetch } = useAuth();
  const [imagePreview, setImagePreview] = useState<string | null>(null);
  const form = useApiForm<EditValues>({
    defaultValues: {
      title: playlist.title,
      description: playlist.description ?? "",
      isPublic: playlist.isPublic,
      image: null,
    },
  });
  const description = form.watch("description");

  const onSubmit = form.submit(
    async ({ title, description, isPublic, image }) => {
      const updated = deserializePlaylist(
        await expectOk<RawPlaylist>(
          await authFetch(`${API_URL}/api/playlists/${playlist.id}`, {
            method: "PUT",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(
              {
                ...(title !== playlist.title ? { title } : {}),
                ...(description !== (playlist.description ?? "")
                  ? { description: description || null }
                  : {}),
                isPublic,
              } satisfies UpdatePlaylistRequest,
            ),
          }),
        ),
      );

      if (image) {
        const fd = new FormData();
        fd.append("file", image);
        await authFetch(`${API_URL}/api/playlists/${playlist.id}/image`, {
          method: "POST",
          body: fd,
        });
      }

      onSaved(updated);
    },
  );

  return (
    <FormProvider {...form}>
      {/* `display: contents` keeps the image-beside-content flex layout while
          still giving us a real <form> for submit + Enter handling. */}
      <form style={{ display: "contents" }} onSubmit={onSubmit}>
        <Controller
          name="image"
          control={form.control}
          render={({ field }) => (
            <ImagePicker
              src={imagePreview ??
                (playlist.imageMime
                  ? `${API_URL}/api/playlists/${playlist.id}/image`
                  : null)}
              alt="Cover"
              size={72}
              onChange={(file) => {
                field.onChange(file);
                setImagePreview(URL.createObjectURL(file));
              }}
            />
          )}
        />

        <div className="playlist-detail-content playlist-edit-content">
          <Controller
            name="title"
            control={form.control}
            render={({ field }) => (
              <CountedInput
                className="playlist-edit-input"
                value={field.value}
                onChange={field.onChange}
                autoFocus
                placeholder={t`Playlist title`}
                maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
              />
            )}
          />

          <Controller
            name="description"
            control={form.control}
            render={({ field }) => (
              <TextEditor
                className="playlist-edit-textarea"
                value={field.value}
                onChange={field.onChange}
                placeholder={t`Description (optional)`}
                autoResize
                rows={2}
                maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
              />
            )}
          />

          <Controller
            name="isPublic"
            control={form.control}
            render={({ field }) => (
              <div className="visibility-toggle playlist-edit-toggle">
                <button
                  type="button"
                  className={field.value ? "active" : ""}
                  onClick={() => field.onChange(true)}
                >
                  <Trans>Public</Trans>
                </button>
                <button
                  type="button"
                  className={!field.value ? "active" : ""}
                  onClick={() => field.onChange(false)}
                >
                  <Trans>Private</Trans>
                </button>
              </div>
            )}
          />

          <FormError title={t`Failed to save`} />

          <div className="form-actions">
            <button
              type="button"
              className="btn-danger"
              onClick={onRequestDelete}
            >
              <Trans>Delete</Trans>
            </button>
            <div className="form-actions-right">
              <button type="button" className="form-cancel" onClick={onCancel}>
                <Trans>Cancel</Trans>
              </button>
              <SubmitButton
                pendingLabel={<Trans>Saving…</Trans>}
                disabled={description.length >
                  VALIDATION.PLAYLIST_DESCRIPTION_MAX}
              >
                <Trans>Save</Trans>
              </SubmitButton>
            </div>
          </div>
        </div>
      </form>
    </FormProvider>
  );
}
