import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { API_URL } from "../config/api.ts";
import type {
  PlaylistWithDumps,
  RawDump,
  RawPlaylist,
  RawPlaylistWithDumps,
} from "../model.ts";
import {
  deserializeDump,
  deserializePlaylist,
  deserializePlaylistWithDumps,
} from "../model.ts";
import { playlistUrl } from "../utils/urls.ts";
import { useAuth } from "../hooks/useAuth.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 { ErrorCard } from "../components/ErrorCard.tsx";
import { Tooltip } from "../components/Tooltip.tsx";
import { friendlyFetchError } from "../utils/apiError.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;

  // 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());

  // 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 [editTitle, setEditTitle] = useState("");
  const [editDescription, setEditDescription] = useState("");
  const [editIsPublic, setEditIsPublic] = useState(true);
  const [editSaving, setEditSaving] = useState(false);
  const [editError, setEditError] = useState<string | null>(null);
  const [confirmDelete, setConfirmDelete] = useState(false);
  const [imageFile, setImageFile] = useState<File | null>(null);
  const [imagePreview, setImagePreview] = useState<string | null>(null);

  // 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());
  }, []);

  const fetchPlaylist = () => {
    if (!playlistId) return;
    setState({ status: "loading" });
    fetch(`${API_URL}/api/playlists/${playlistId}`, {
      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();
      })
      .catch((err) => {
        setState({
          status: "error",
          error: friendlyFetchError(err),
        });
      });
  };

  useEffect(() => {
    fetchPlaylist();
  }, [playlistId]);

  // 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(() => {});
        }
      }

      // Apply the server-authoritative order: active dumps in ev.dumpIds order,
      // fading dumps (not in newIds) appended at the end.
      setState((s) => {
        if (s.status !== "loaded") return s;
        const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d]));
        return {
          ...s,
          playlist: {
            ...s.playlist,
            dumps: [
              ...ev.dumpIds!
                .filter((id) => dumpMap.has(id))
                .map((id) => dumpMap.get(id)!),
              ...s.playlist.dumps.filter((d) => !newIds.has(d.id)),
            ],
          },
        };
      });
      dumpOrderRef.current = ev.dumpIds!;
    } 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]);

  // Filter out globally deleted dumps (dump was deleted entirely, not just removed from playlist)
  useEffect(() => {
    if (deletedDumpIds.size === 0) return;
    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) }),
      });
    } 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) return;
    cancels.current.get(dumpId)?.();
    setActiveDumpIds((prev) => new Set([...prev, dumpId]));
    // Re-add server-side since DELETE already fired
    authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
      method: "POST",
    }).catch(() => {});
  };

  const openEdit = () => {
    if (state.status !== "loaded") return;
    setEditTitle(state.playlist.title);
    setEditDescription(state.playlist.description ?? "");
    setEditIsPublic(state.playlist.isPublic);
    setImageFile(null);
    setImagePreview(null);
    setEditError(null);
    setEditOpen(true);
  };

  const handleEditSave = async () => {
    if (!playlistId || state.status !== "loaded") return;
    setEditSaving(true);
    setEditError(null);
    try {
      const updateRes = await authFetch(
        `${API_URL}/api/playlists/${playlistId}`,
        {
          method: "PUT",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            title: editTitle,
            description: editDescription || undefined,
            isPublic: editIsPublic,
          }),
        },
      );
      const updateJson = await updateRes.json() as {
        success: boolean;
        data: RawPlaylist;
      };
      const updatedPlaylist = updateJson.success
        ? deserializePlaylist(updateJson.data)
        : null;

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

      setEditOpen(false);
      if (updatedPlaylist) {
        navigate(playlistUrl(updatedPlaylist), { replace: true });
      } else {
        fetchPlaylist();
      }
    } catch (err) {
      setEditError(friendlyFetchError(err));
    } finally {
      setEditSaving(false);
    }
  };

  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">Loading playlist…</p>
      </PageShell>
    );
  }

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

  const { playlist } = state;
  const isOwner = !!user && user.id === playlist.userId;

  // 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
            ? (
              <ImagePicker
                src={imagePreview ??
                  (playlist.imageMime
                    ? `${API_URL}/api/playlists/${playlist.id}/image`
                    : null)}
                alt="Cover"
                size={72}
                onChange={(file) => {
                  setImageFile(file);
                  setImagePreview(URL.createObjectURL(file));
                }}
              />
            )
            : playlist.imageMime && (
              <img
                src={`${API_URL}/api/playlists/${playlist.id}/image`}
                alt=""
                className="playlist-detail-img"
              />
            )}

          <div className="playlist-detail-content">
            {editOpen
              ? (
                <div className="playlist-detail-title-row">
                  <input
                    type="text"
                    className="playlist-edit-input"
                    value={editTitle}
                    onChange={(e) => setEditTitle(e.target.value)}
                    autoFocus
                  />
                  <button
                    type="button"
                    className="btn-primary"
                    disabled={editSaving}
                    onClick={handleEditSave}
                  >
                    {editSaving ? "Saving…" : "Save"}
                  </button>
                  <button
                    type="button"
                    className="form-cancel"
                    onClick={() => setEditOpen(false)}
                  >
                    Cancel
                  </button>
                  <button
                    type="button"
                    className="btn-danger"
                    onClick={() => setConfirmDelete(true)}
                  >
                    Delete
                  </button>
                </div>
              )
              : (
                <div className="playlist-detail-title-row">
                  <h1 className="playlist-detail-title">{playlist.title}</h1>
                  {!isOwner && (
                    <FollowPlaylistButton
                      targetPlaylistId={playlist.id}
                      isPublic={playlist.isPublic}
                    />
                  )}
                  {isOwner && (
                    <button
                      type="button"
                      className="playlist-edit-btn"
                      onClick={openEdit}
                    >
                      Edit
                    </button>
                  )}
                </div>
              )}

            {editOpen
              ? (
                <TextEditor
                  className="playlist-edit-textarea"
                  value={editDescription}
                  onChange={setEditDescription}
                  placeholder="Description (optional)"
                  autoResize
                  rows={1}
                />
              )
              : playlist.description && (
                <Markdown className="playlist-detail-description">
                  {playlist.description}
                </Markdown>
              )}

            <div className="playlist-detail-meta">
              {editOpen
                ? (
                  <div className="visibility-toggle playlist-edit-toggle">
                    <button
                      type="button"
                      className={editIsPublic ? "active" : ""}
                      onClick={() => setEditIsPublic(true)}
                    >
                      Public
                    </button>
                    <button
                      type="button"
                      className={!editIsPublic ? "active" : ""}
                      onClick={() => setEditIsPublic(false)}
                    >
                      Private
                    </button>
                  </div>
                )
                : (
                  <>
                    <span
                      className={`playlist-badge${
                        playlist.isPublic ? "" : " playlist-badge--private"
                      }`}
                    >
                      {playlist.isPublic ? "public" : "private"}
                    </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={`Edited ${playlist.updatedAt.toLocaleString()}`}
                      >
                        <span className="playlist-edited-label">
                          edited {relativeTime(playlist.updatedAt)}
                        </span>
                      </Tooltip>
                    )}
                  </>
                )}
            </div>
            {editError && (
              <ErrorCard title="Failed to save" message={editError} />
            )}
          </div>
        </div>
      </div>

      {visibleDumps.length === 0
        ? <p className="empty-state">No dumps in this playlist yet.</p>
        : (
          <div
            className="playlist-dump-list"
            onDragOver={isOwner
              ? (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={isOwner && isActive}
                  onDragStart={isOwner && isActive
                    ? () => handleDragStart(activeIndex)
                    : undefined}
                  onDragOver={isOwner && isActive
                    ? (e) => handleDragOver(e, activeIndex)
                    : undefined}
                  onDragEnd={isOwner ? handleDragEnd : undefined}
                >
                  {isOwner && 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}
                  />
                  {isOwner && (isActive
                    ? (
                      <button
                        type="button"
                        className="playlist-remove-btn"
                        onClick={() => handleRemoveDump(dump.id)}
                        aria-label="Remove from playlist"
                      >
                        ✕
                      </button>
                    )
                    : phase === "cooldown" && (
                      <button
                        type="button"
                        className="playlist-cancel-btn"
                        onClick={() => handleCancelRemove(dump.id)}
                        aria-label="Cancel removal"
                      >
                        Undo
                      </button>
                    ))}
                </div>
              );
            })}
          </div>
        )}
      {confirmDelete && (
        <ConfirmModal
          message="Delete this playlist? This cannot be undone."
          confirmLabel="Delete playlist"
          onConfirm={handleDelete}
          onCancel={() => setConfirmDelete(false)}
        />
      )}
    </PageShell>
  );
}
