const { useState, useEffect, useMemo, useRef, useCallback, createContext, useContext } = React;

/* =========================================================================
 * Config
 * ========================================================================= */
const CATEGORIES = [
  { key: "theme", label: "Themes",
    col: "border-violet-200 bg-violet-50", head: "border-violet-200 bg-violet-100 text-violet-700" },
  { key: "holiday", label: "Holidays",
    col: "border-amber-200 bg-amber-50", head: "border-amber-200 bg-amber-100 text-amber-700" },
  { key: "grade", label: "Grades",
    col: "border-emerald-200 bg-emerald-50", head: "border-emerald-200 bg-emerald-100 text-emerald-700" },
];

const FILE_KINDS = [
  { key: "chord", label: "Chords", text: true },
  { key: "lyrics", label: "Lyrics", text: true },
  { key: "notated", label: "Notated Music", text: false },
  { key: "audio", label: "Audio", text: false },
  { key: "video", label: "Video", text: false },
  { key: "props", label: "Props", text: false },
  { key: "other", label: "Other", text: false },
];

/* =========================================================================
 * Auth / API
 * ========================================================================= */
const AuthContext = createContext(null);
const useAuth = () => useContext(AuthContext);

function makeToken(user, pass) {
  return btoa(unescape(encodeURIComponent(user + ":" + pass)));
}

/* =========================================================================
 * Icons
 * ========================================================================= */
const PATHS = {
  search: "M21 21l-4.3-4.3M11 19a8 8 0 100-16 8 8 0 000 16z",
  plus: "M12 5v14M5 12h14",
  trash: "M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m2 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6",
  download: "M12 3v12m0 0l-4-4m4 4l4-4M4 21h16",
  upload: "M12 21V9m0 0l-4 4m4-4l4 4M4 3h16",
  edit: "M12 20h9M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4L16.5 3.5z",
  close: "M18 6L6 18M6 6l12 12",
  chevronDown: "M6 9l6 6 6-6",
  chevronRight: "M9 6l6 6-6 6",
  music: "M9 18V5l12-2v13M9 18a3 3 0 11-6 0 3 3 0 016 0zm12-2a3 3 0 11-6 0 3 3 0 016 0z",
  film: "M23 7l-7 5 7 5V7zM14 5H3a2 2 0 00-2 2v10a2 2 0 002 2h11a2 2 0 002-2V7a2 2 0 00-2-2z",
  doc: "M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zM14 2v6h6",
  back: "M19 12H5M12 19l-7-7 7-7",
  arrowUp: "M12 19V5M5 12l7-7 7 7",
  arrowDown: "M12 5v14M19 12l-7 7-7-7",
  tag: "M20.6 13.4L12 22l-9-9V4a1 1 0 011-1h9l7.6 7.6a2 2 0 010 2.8zM7 7h.01",
  list: "M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01",
  menu: "M4 6h16M4 12h16M4 18h16",
  dots: "M12 5h.01M12 12h.01M12 19h.01",
  layers: "M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5",
  save: "M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2zM17 21v-8H7v8M7 3v5h8",
  logout: "M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9",
  play: "M5 3l14 9-14 9V3z",
  refresh: "M23 4v6h-6M1 20v-6h6M3.5 9a9 9 0 0114.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0020.5 15",
  check: "M20 6L9 17l-5-5",
  grip: "M9 5h.01M9 12h.01M9 19h.01M15 5h.01M15 12h.01M15 19h.01",
  clock: "M12 7v5l3 2M12 21a9 9 0 110-18 9 9 0 010 18z",
  copy: "M9 9h9a2 2 0 012 2v9a2 2 0 01-2 2H9a2 2 0 01-2-2v-9a2 2 0 012-2zM5 15a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2",
  restore: "M3 3v6h6M3.5 9a9 9 0 11-1.5 5",
  printer: "M6 9V3h12v6M6 18H4a2 2 0 01-2-2v-4a2 2 0 012-2h16a2 2 0 012 2v4a2 2 0 01-2 2h-2M6 14h12v7H6z",
  share: "M12 3v13M8 7l4-4 4 4M20 14v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5",
  moon: "M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z",
  sun: "M12 7a5 5 0 100 10 5 5 0 000-10zM12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42",
};
function Icon({ name, className = "w-4 h-4", stroke = 2 }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor"
      strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round">
      <path d={PATHS[name]} />
    </svg>
  );
}

/* Light/dark theme toggle. The initial class is set in index.html before paint;
   this just flips it and remembers the choice. */
function ThemeToggle() {
  const [dark, setDark] = useState(() =>
    typeof document !== "undefined" && document.documentElement.classList.contains("dark"));
  function toggle() {
    const next = !dark;
    setDark(next);
    document.documentElement.classList.toggle("dark", next);
    try { localStorage.setItem("theme", next ? "dark" : "light"); } catch (e) {}
  }
  return (
    <Button variant="ghost" className="w-full justify-start" onClick={toggle}>
      <Icon name={dark ? "sun" : "moon"} /> {dark ? "Light mode" : "Dark mode"}
    </Button>
  );
}

/* =========================================================================
 * UI primitives
 * ========================================================================= */
function Button({ variant = "default", size = "md", className = "", children, ...props }) {
  const base = "inline-flex items-center justify-center gap-1.5 font-medium rounded-lg transition-all active:scale-[.98] disabled:opacity-50 disabled:pointer-events-none";
  const sizes = { sm: "text-xs px-2.5 py-1.5", md: "text-sm px-3.5 py-2", icon: "p-2" };
  const variants = {
    default: "bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 hover:border-slate-300 shadow-sm",
    primary: "bg-brand-600 text-white hover:bg-brand-700 shadow-sm shadow-brand-600/20",
    ghost: "text-slate-500 hover:bg-slate-100 hover:text-slate-800",
    danger: "text-red-600 hover:bg-red-50",
    subtle: "bg-slate-100 text-slate-700 hover:bg-slate-200",
  };
  return (
    <button className={`${base} ${sizes[size]} ${variants[variant]} ${className}`} {...props}>
      {children}
    </button>
  );
}

function Field({ label, children }) {
  return (
    <label className="block">
      <span className="text-xs font-semibold uppercase tracking-wide text-slate-500">{label}</span>
      <div className="mt-1">{children}</div>
    </label>
  );
}

const inputCls =
  "w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800 placeholder-slate-400 outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-100";

function Modal({ open, onClose, children, size = "lg" }) {
  useEffect(() => {
    if (!open) return;
    const h = (e) => e.key === "Escape" && onClose();
    window.addEventListener("keydown", h);
    return () => window.removeEventListener("keydown", h);
  }, [open, onClose]);
  if (!open) return null;
  const widths = { md: "max-w-lg", lg: "max-w-3xl", xl: "max-w-5xl" };
  return (
    <div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-slate-900/40 p-4 backdrop-blur-sm sm:p-8"
      onMouseDown={onClose}>
      <div className={`fade-in my-4 w-full ${widths[size]} rounded-2xl bg-white shadow-2xl ring-1 ring-slate-900/5`}
        onMouseDown={(e) => e.stopPropagation()}>
        {children}
      </div>
    </div>
  );
}

/* Toast system */
const ToastContext = createContext(() => {});
const useToast = () => useContext(ToastContext);
function ToastHost({ children }) {
  const [toasts, setToasts] = useState([]);
  const idRef = useRef(0);
  const push = useCallback((msg, type = "info") => {
    const id = ++idRef.current;
    setToasts((t) => [...t, { id, msg, type }]);
    setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 3200);
  }, []);
  return (
    <ToastContext.Provider value={push}>
      {children}
      <div className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2">
        {toasts.map((t) => (
          <div key={t.id}
            className={`fade-in flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium text-white shadow-lg ${
              t.type === "error" ? "bg-red-600" : t.type === "success" ? "bg-emerald-600" : "bg-slate-800"
            }`}>
            {t.msg}
          </div>
        ))}
      </div>
    </ToastContext.Provider>
  );
}

function Confirm({ open, title, message, onConfirm, onClose, confirmLabel = "Delete", danger = true }) {
  return (
    <Modal open={open} onClose={onClose} size="md">
      <div className="p-6">
        <h3 className="text-lg font-semibold text-slate-800">{title}</h3>
        <p className="mt-2 text-sm text-slate-500">{message}</p>
        <div className="mt-6 flex justify-end gap-2">
          <Button onClick={onClose}>Cancel</Button>
          <Button variant="primary" className={danger ? "!bg-red-600 hover:!bg-red-700" : ""}
            onClick={() => { onConfirm(); onClose(); }}>{confirmLabel}</Button>
        </div>
      </div>
    </Modal>
  );
}

function Spinner({ className = "w-5 h-5" }) {
  return (
    <svg className={`animate-spin ${className}`} viewBox="0 0 24 24" fill="none">
      <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
      <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4l3-3-3-3v2a10 10 0 100 20v-2a8 8 0 01-8-8z" />
    </svg>
  );
}

/* =========================================================================
 * Login
 * ========================================================================= */
function Login({ onLogin }) {
  const [user, setUser] = useState("");
  const [pass, setPass] = useState("");
  const [err, setErr] = useState("");
  const [busy, setBusy] = useState(false);

  async function submit(e) {
    e.preventDefault();
    setBusy(true);
    setErr("");
    const token = makeToken(user, pass);
    try {
      const res = await fetch("/api/me", { headers: { Authorization: "Basic " + token } });
      if (!res.ok) throw new Error("Invalid username or password");
      const data = await res.json();
      onLogin(token, data.username);
    } catch (e) {
      setErr(e.message || "Login failed");
    } finally {
      setBusy(false);
    }
  }

  return (
    <div className="flex min-h-full items-center justify-center bg-gradient-to-br from-slate-50 via-brand-50 to-slate-100 p-4">
      <form onSubmit={submit}
        className="fade-in w-full max-w-sm rounded-2xl bg-white p-8 shadow-xl ring-1 ring-slate-900/5">
        <div className="mb-6 flex flex-col items-center text-center">
          <div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-600 text-white shadow-lg shadow-brand-600/30">
            <Icon name="music" className="h-7 w-7" />
          </div>
          <h1 className="mt-4 text-2xl font-bold text-slate-800">Songster</h1>
          <p className="mt-1 text-sm text-slate-500">Your song library & playlist manager</p>
        </div>
        {err && <div className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{err}</div>}
        <div className="space-y-3">
          <input className={inputCls} placeholder="Username" value={user} autoFocus
            onChange={(e) => setUser(e.target.value)} />
          <input className={inputCls} placeholder="Password" type="password" value={pass}
            onChange={(e) => setPass(e.target.value)} />
        </div>
        <Button variant="primary" className="mt-5 w-full" type="submit" disabled={busy}>
          {busy ? <Spinner className="w-4 h-4" /> : "Sign in"}
        </Button>
      </form>
    </div>
  );
}

/* =========================================================================
 * Tag chips / pickers
 * ========================================================================= */
const CAT_COLORS = {
  theme: "bg-brand-50 text-brand-700 ring-brand-200",
  holiday: "bg-amber-50 text-amber-700 ring-amber-200",
  grade: "bg-emerald-50 text-emerald-700 ring-emerald-200",
};
function TagChip({ tag, onRemove }) {
  return (
    <span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ring-1 ${CAT_COLORS[tag.category] || "bg-slate-100 text-slate-600 ring-slate-200"}`}>
      {tag.name}
      {onRemove && (
        <button onClick={onRemove} className="opacity-60 hover:opacity-100">
          <Icon name="close" className="h-3 w-3" stroke={2.5} />
        </button>
      )}
    </span>
  );
}

/* Multi-select tag editor for one category */
function TagPicker({ category, label, allTags, selectedIds, onChange, api, onTagsChanged }) {
  const [open, setOpen] = useState(false);
  const [q, setQ] = useState("");
  const ref = useRef(null);
  const cats = allTags.filter((t) => t.category === category);
  const selected = cats.filter((t) => selectedIds.includes(t.id));

  useEffect(() => {
    const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", h);
    return () => document.removeEventListener("mousedown", h);
  }, []);

  const filtered = cats.filter((t) => t.name.toLowerCase().includes(q.toLowerCase()));
  const exactMatch = cats.some((t) => t.name.toLowerCase() === q.trim().toLowerCase());

  async function createAndAdd() {
    const name = q.trim();
    if (!name) return;
    const tag = await api(`/api/tags`, { method: "POST", body: { name, category } });
    onTagsChanged();
    onChange([...selectedIds, tag.id]);
    setQ("");
  }

  function toggle(id) {
    onChange(selectedIds.includes(id) ? selectedIds.filter((x) => x !== id) : [...selectedIds, id]);
  }

  return (
    <div ref={ref} className="relative">
      <span className="text-xs font-semibold uppercase tracking-wide text-slate-500">{label}</span>
      <div className="mt-1 flex min-h-[42px] flex-wrap items-center gap-1.5 rounded-lg border border-slate-200 bg-white p-2"
        onClick={() => setOpen(true)}>
        {selected.map((t) => (
          <TagChip key={t.id} tag={t} onRemove={() => toggle(t.id)} />
        ))}
        <input
          className="min-w-[80px] flex-1 bg-transparent px-1 text-sm outline-none placeholder-slate-400"
          placeholder={selected.length ? "" : "Add…"}
          value={q}
          onFocus={() => setOpen(true)}
          onChange={(e) => { setQ(e.target.value); setOpen(true); }}
          onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); if (!exactMatch && q.trim()) createAndAdd(); } }}
        />
      </div>
      {open && (
        <div className="absolute z-30 mt-1 max-h-60 w-full overflow-y-auto rounded-lg border border-slate-200 bg-white p-1 shadow-lg">
          {filtered.map((t) => (
            <button key={t.id} onClick={() => toggle(t.id)}
              className="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-left text-sm hover:bg-slate-50">
              <span>{t.name}</span>
              {selectedIds.includes(t.id) && <Icon name="check" className="h-4 w-4 text-brand-600" />}
            </button>
          ))}
          {q.trim() && !exactMatch && (
            <button onClick={createAndAdd}
              className="flex w-full items-center gap-1.5 rounded-md px-2.5 py-1.5 text-left text-sm text-brand-600 hover:bg-brand-50">
              <Icon name="plus" className="h-3.5 w-3.5" /> Create “{q.trim()}”
            </button>
          )}
          {!filtered.length && !q.trim() && <div className="px-2.5 py-2 text-sm text-slate-400">No tags yet</div>}
        </div>
      )}
    </div>
  );
}

/* Reusable tag-filter columns + A-Z, shared by the songs list and the playlist
   "add songs" picker. Parent owns the state. */
function TagFilters({ allTags, selectedTags, toggleTag, letter, setLetter, columnHeight = "h-60" }) {
  return (
    <div>
      <div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
        {CATEGORIES.map((cat) => {
          const cats = allTags.filter((t) => t.category === cat.key);
          if (!cats.length) return null;
          return (
            <div key={cat.key} className={`flex flex-col overflow-hidden rounded-lg border ${cat.col || "border-slate-200 bg-slate-50"}`}>
              <div className={`border-b px-3 py-1.5 text-sm font-bold uppercase tracking-wide ${cat.head || "border-slate-200 bg-slate-100 text-slate-600"}`}>
                {cat.label}
              </div>
              <div className={`${columnHeight} space-y-1 overflow-y-auto p-2`}>
                {cats.map((t) => {
                  const on = selectedTags.includes(t.id);
                  return (
                    <button key={t.id} onClick={() => toggleTag(t.id)}
                      className={`block w-full rounded-md px-2.5 py-1.5 text-left text-sm font-medium leading-tight ring-1 transition ${
                        on ? "bg-brand-600 text-white ring-brand-600" : "bg-white text-slate-700 ring-slate-300 hover:bg-brand-50 hover:ring-slate-400"
                      }`}>
                      {t.name}
                    </button>
                  );
                })}
              </div>
            </div>
          );
        })}
      </div>
      <div className="mt-3 flex flex-wrap items-center gap-1">
        <button onClick={() => setLetter("")}
          className={`rounded-md px-2.5 py-1 text-sm font-semibold ${!letter ? "bg-slate-800 text-white" : "text-slate-600 hover:bg-slate-100"}`}>All</button>
        {LETTERS.map((l) => (
          <button key={l} onClick={() => setLetter(letter === l ? "" : l)}
            className={`w-7 rounded-md py-1 text-sm font-semibold ${letter === l ? "bg-brand-600 text-white" : "text-slate-600 hover:bg-slate-100"}`}>{l}</button>
        ))}
      </div>
    </div>
  );
}

/* =========================================================================
 * Songs list view
 * ========================================================================= */
const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");

function SongsView({ onOpen, allTags, refreshTags }) {
  const { api } = useAuth();
  const toast = useToast();
  const [data, setData] = useState({ items: [], total: 0 });
  const [loading, setLoading] = useState(true);
  const [q, setQ] = useState("");
  const [debouncedQ, setDebouncedQ] = useState("");
  const [selectedTags, setSelectedTags] = useState([]);
  const [sort, setSort] = useState("title");
  const [order, setOrder] = useState("asc");
  const [letter, setLetter] = useState("");
  const [page, setPage] = useState(1);
  const [creating, setCreating] = useState(false);
  const [showFilters, setShowFilters] = useState(false); // mobile filter panel
  const pageSize = 50;

  useEffect(() => {
    const t = setTimeout(() => setDebouncedQ(q), 250);
    return () => clearTimeout(t);
  }, [q]);
  useEffect(() => { setPage(1); }, [debouncedQ, selectedTags, sort, order, letter]);

  const load = useCallback(async () => {
    setLoading(true);
    const params = new URLSearchParams({ sort, order, page: String(page), page_size: String(pageSize) });
    if (debouncedQ) params.set("q", debouncedQ);
    if (letter) params.set("letter", letter);
    if (selectedTags.length) params.set("tag_ids", selectedTags.join(","));
    try {
      const res = await api(`/api/songs?${params}`);
      setData(res);
    } finally {
      setLoading(false);
    }
  }, [api, sort, order, page, debouncedQ, letter, selectedTags]);

  useEffect(() => { load(); }, [load]);

  async function createSong() {
    setCreating(true);
    try {
      const song = await api(`/api/songs`, { method: "POST", body: { title: "Untitled song" } });
      onOpen(song.id);
    } finally {
      setCreating(false);
    }
  }

  const totalPages = Math.max(1, Math.ceil(data.total / pageSize));

  function toggleTag(id) {
    setSelectedTags((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
  }

  const hasFilters = q || selectedTags.length > 0 || letter;
  function clearFilters() {
    setQ("");
    setSelectedTags([]);
    setLetter("");
  }

  return (
    <div className="flex h-full flex-col">
      {/* Toolbar */}
      <div className="border-b border-slate-200 bg-white px-4 py-3 md:px-6 md:py-4">
        <div className="flex items-center justify-between gap-4">
          <div>
            <h1 className="text-xl font-bold text-slate-900 md:text-2xl">Songs</h1>
            <p className="text-sm text-slate-600 md:text-base">{data.total.toLocaleString()} songs in your library</p>
          </div>
          <Button variant="primary" onClick={createSong} disabled={creating}>
            <Icon name="plus" /> <span className="hidden sm:inline">New Song</span><span className="sm:hidden">New</span>
          </Button>
        </div>
        <div className="mt-3 flex flex-wrap items-center gap-2 md:mt-4">
          <div className="relative min-w-[160px] flex-1">
            <Icon name="search" className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
            <input className={inputCls + " pl-9"} placeholder="Search songs…"
              value={q} onChange={(e) => setQ(e.target.value)} />
          </div>
          <select className={inputCls + " w-auto"} value={sort} onChange={(e) => setSort(e.target.value)}>
            <option value="title">Sort: Title</option>
            <option value="composer">Sort: Composer</option>
            <option value="publication">Sort: Publication</option>
            <option value="text_source">Sort: Text Source</option>
            <option value="updated_at">Sort: Recently updated</option>
          </select>
          <Button onClick={() => setOrder((o) => (o === "asc" ? "desc" : "asc"))}>
            <Icon name={order === "asc" ? "arrowDown" : "arrowUp"} /> {order === "asc" ? "A→Z" : "Z→A"}
          </Button>
          <Button onClick={clearFilters} disabled={!hasFilters} title="Clear search, tags and A-Z filter">
            <Icon name="close" /> Clear
          </Button>
          {/* Mobile filter toggle */}
          <Button className="md:hidden" onClick={() => setShowFilters((v) => !v)}>
            <Icon name="tag" /> Filters
            {(selectedTags.length + (letter ? 1 : 0)) > 0 &&
              <span className="ml-1 rounded-full bg-brand-600 px-1.5 text-xs font-semibold text-white">{selectedTags.length + (letter ? 1 : 0)}</span>}
          </Button>
        </div>

        {/* Filters (tags + A-Z): collapsible on mobile, always shown on desktop */}
        <div className={((showFilters ? "block" : "hidden") + " md:block") + " mt-3"}>
          <TagFilters allTags={allTags} selectedTags={selectedTags} toggleTag={toggleTag}
            letter={letter} setLetter={setLetter} />
        </div>{/* /filters */}
      </div>

      {/* Table */}
      <div className="relative flex-1 overflow-y-auto">
        {loading && (
          <div className="absolute inset-x-0 top-0 z-10 flex justify-center py-3">
            <div className="flex items-center gap-2 rounded-full bg-white px-3 py-1 text-xs text-slate-500 shadow ring-1 ring-slate-200">
              <Spinner className="h-3.5 w-3.5" /> Loading
            </div>
          </div>
        )}
        {!loading && !data.items.length ? (
          <div className="flex h-full flex-col items-center justify-center text-slate-400">
            <Icon name="music" className="h-10 w-10" />
            <p className="mt-2 text-sm">No songs match your filters.</p>
          </div>
        ) : (
          <table className="w-full border-collapse text-base">
            <thead className="sticky top-0 z-[5] bg-slate-100 text-left text-xs font-bold uppercase tracking-wide text-slate-600">
              <tr>
                <th className="px-4 py-3 md:px-6">Title</th>
                <th className="hidden px-4 py-3 md:table-cell">Composer</th>
                <th className="hidden px-4 py-3 lg:table-cell">Publication</th>
                <th className="hidden px-4 py-3 md:table-cell">Tags</th>
              </tr>
            </thead>
            <tbody>
              {data.items.map((s) => (
                <tr key={s.id} onClick={() => onOpen(s.id)}
                  className="cursor-pointer border-b border-slate-200 hover:bg-brand-50/60">
                  <td className="px-4 py-3.5 md:px-6">
                    <div className="text-base font-semibold text-slate-900">{s.title}</div>
                    {/* On phones, fold composer + tag count under the title */}
                    <div className="mt-0.5 flex items-center gap-2 text-sm text-slate-500 md:hidden">
                      {s.composer && <span className="truncate">{s.composer}</span>}
                      {s.tags.length > 0 && <span className="shrink-0 text-slate-400">{s.tags.length} tag{s.tags.length > 1 ? "s" : ""}</span>}
                    </div>
                  </td>
                  <td className="hidden px-4 py-3.5 text-slate-700 md:table-cell">{s.composer}</td>
                  <td className="hidden px-4 py-3.5 text-slate-600 lg:table-cell">{s.publication}</td>
                  <td className="hidden px-4 py-3.5 md:table-cell">
                    <div className="flex flex-wrap gap-1">
                      {s.tags.slice(0, 4).map((t) => <TagChip key={t.id} tag={t} />)}
                      {s.tags.length > 4 && <span className="text-xs text-slate-400">+{s.tags.length - 4}</span>}
                    </div>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        )}
      </div>

      {/* Pagination */}
      <div className="pb-safe flex items-center justify-between border-t border-slate-200 bg-white px-4 py-2.5 text-sm text-slate-500 md:px-6">
        <span>Page {data.page} of {totalPages}</span>
        <div className="flex gap-1">
          <Button size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Previous</Button>
          <Button size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>Next</Button>
        </div>
      </div>
    </div>
  );
}

/* =========================================================================
 * OnlyOffice embedded editor (full-fidelity Word editing)
 * ========================================================================= */
const _scriptCache = {};
function loadScript(src) {
  if (_scriptCache[src]) return _scriptCache[src];
  _scriptCache[src] = new Promise((resolve, reject) => {
    const s = document.createElement("script");
    s.src = src;
    s.onload = resolve;
    s.onerror = () => reject(new Error("Failed to load " + src));
    document.body.appendChild(s);
  });
  return _scriptCache[src];
}

function OnlyOfficeEditor({ file, docServerUrl, onClose, onSaved }) {
  const { api } = useAuth();
  const toast = useToast();
  const holderId = "oo-holder-" + file.id;
  const instRef = useRef(null);
  const pendingSaveAs = useRef(null); // {name, format, kind} awaiting onDownloadAs
  const [status, setStatus] = useState("loading"); // loading | ready | error
  const [errMsg, setErrMsg] = useState("");
  const [saveAsOpen, setSaveAsOpen] = useState(false);
  const [saveAsBusy, setSaveAsBusy] = useState(false);
  const [saveAsName, setSaveAsName] = useState("");
  const [saveAsFormat, setSaveAsFormat] = useState("docx");
  const [saveAsKind, setSaveAsKind] = useState(file.kind);

  function openSaveAs() {
    setSaveAsName((file.name || "document").replace(/\.[^.]+$/, ""));
    setSaveAsFormat("docx");
    setSaveAsKind(file.kind);
    setSaveAsOpen(true);
  }

  // Save As exports the *live* document via the editor's downloadAs (so unsaved
  // edits are included and the format conversion is done client-side). The result
  // URL comes back through the onDownloadAs event, which finishes the save.
  function submitSaveAs() {
    const name = saveAsName.trim();
    if (!name || !instRef.current) return;
    pendingSaveAs.current = { name, format: saveAsFormat, kind: saveAsKind };
    setSaveAsBusy(true);
    setSaveAsOpen(false);
    try {
      instRef.current.downloadAs(saveAsFormat);
    } catch (e) {
      pendingSaveAs.current = null;
      setSaveAsBusy(false);
      toast("Couldn’t start the export", "error");
    }
  }

  function onExported(event) {
    const pend = pendingSaveAs.current;
    if (!pend) return; // a plain download, not our Save As
    pendingSaveAs.current = null;
    const d = event && event.data;
    const url = (d && d.url) || (typeof d === "string" ? d : null);
    const fileType = (d && d.fileType) || pend.format;
    if (!url) { setSaveAsBusy(false); toast("Export failed", "error"); return; }
    api(`/api/files/${file.id}/save-as`, {
      method: "POST",
      body: { name: pend.name, format: pend.format, kind: pend.kind, url, fileType },
    })
      .then((created) => toast(`Saved as “${created.name}”`, "success"))
      .catch((e) => toast("Couldn’t save a copy — " + (e.message || "try again"), "error"))
      .finally(() => setSaveAsBusy(false));
  }

  // Close just dismisses the editor. Saving is done inside OnlyOffice (Ctrl/Cmd+S).
  function close() {
    try { instRef.current && instRef.current.destroyEditor(); } catch (e) {}
    instRef.current = null;
    onClose();
    setTimeout(() => onSaved && onSaved(), 400);
  }

  const deadRef = useRef(false);

  function reinit() {
    // rebuild the editor (after closing history or restoring a version)
    try { instRef.current && instRef.current.destroyEditor(); } catch (e) {}
    instRef.current = null;
    setTimeout(() => { if (!deadRef.current) initEditor(); }, 300);
  }

  async function initEditor() {
    setStatus("loading");
    try {
      await loadScript(`${docServerUrl}/web-apps/apps/api/documents/api.js`);
      if (deadRef.current) return;
      if (!window.DocsAPI) throw new Error("OnlyOffice API did not load");
      const { config } = await api(`/api/files/${file.id}/onlyoffice-config`);
      if (deadRef.current) return;
      instRef.current = new window.DocsAPI.DocEditor(holderId, {
        ...config,
        width: "100%",
        height: "100%",
        type: window.innerWidth < 768 ? "mobile" : "desktop",
        events: {
          onAppReady: () => setStatus("ready"),
          onError: (e) => { setStatus("error"); setErrMsg((e && e.data) || "Editor error"); },
          onRequestClose: () => close(),
          onDownloadAs: (e) => onExported(e),
          // native version history, backed by Songster's file_versions
          onRequestHistory: async () => {
            try { instRef.current.refreshHistory(await api(`/api/files/${file.id}/oo-history`)); } catch (e) {}
          },
          onRequestHistoryData: async (ev) => {
            try { instRef.current.setHistoryData(await api(`/api/files/${file.id}/oo-history/${ev.data}`)); } catch (e) {}
          },
          onRequestHistoryClose: () => reinit(),
          onRequestRestore: async (ev) => {
            const v = ev && ev.data && (ev.data.version != null ? ev.data.version : ev.data);
            try {
              await api(`/api/files/${file.id}/oo-history/${v}/restore`, { method: "POST" });
              toast("Version restored", "success");
              reinit();
            } catch (e) { toast("Couldn’t restore that version", "error"); }
          },
        },
      });
    } catch (e) {
      if (!deadRef.current) { setStatus("error"); setErrMsg(e.message || String(e)); }
    }
  }

  useEffect(() => {
    deadRef.current = false;
    initEditor();
    return () => { deadRef.current = true; try { instRef.current && instRef.current.destroyEditor(); } catch (e) {} };
  }, []);

  return (
    <div className="fixed inset-0 z-50 flex flex-col bg-slate-900/60">
      <div className="pt-safe flex items-center justify-between gap-2 bg-white px-3 py-2 shadow md:px-4">
        <h3 className="flex min-w-0 items-center gap-2 truncate text-sm font-semibold text-slate-800">
          <Icon name="doc" className="h-4 w-4 shrink-0 text-slate-400" /> <span className="truncate">{file.name}</span>
          <span className="hidden rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700 ring-1 ring-emerald-200 sm:inline">OnlyOffice</span>
        </h3>
        <div className="flex shrink-0 items-center gap-1.5 md:gap-3">
          {status === "loading" && <span className="hidden items-center gap-1.5 text-xs text-slate-500 sm:flex"><Spinner className="h-3.5 w-3.5" /> Loading…</span>}
          <Button size="sm" variant="subtle" onClick={openSaveAs}><Icon name="copy" /> <span className="hidden sm:inline">Save As</span></Button>
          <Button size="sm" variant="primary" onClick={close}><Icon name="close" /> <span className="hidden sm:inline">Close</span></Button>
        </div>
      </div>
      <div className="border-b border-amber-100 bg-amber-50 px-4 py-1.5 text-center text-xs font-medium text-amber-700">
        Save your changes in the editor (⌘S / Ctrl+S) before closing — <b>closing does not save.</b>
      </div>
      <div className="relative flex-1">
        {status === "error" && (
          <div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-white text-slate-500">
            <Icon name="doc" className="h-10 w-10" />
            <p className="text-sm">Couldn’t load the OnlyOffice editor.</p>
            <p className="max-w-md text-center text-xs text-slate-400">{errMsg}</p>
            <Button size="sm" onClick={onClose}>Close</Button>
          </div>
        )}
        <div id={holderId} className="h-full w-full" />
      </div>

      <Modal open={saveAsOpen} onClose={() => setSaveAsOpen(false)} size="md">
        <div className="p-6">
          <h3 className="text-lg font-semibold text-slate-800">Save a copy</h3>
          <p className="mt-1 text-sm text-slate-500">Creates a new, separate file — the original stays as it is.</p>

          <label className="mt-4 block text-xs font-medium text-slate-500">Name</label>
          <input autoFocus className={inputCls + " mt-1"} value={saveAsName}
            onChange={(e) => setSaveAsName(e.target.value)}
            onKeyDown={(e) => { if (e.key === "Enter") submitSaveAs(); }} />

          <div className="mt-3 grid grid-cols-2 gap-3">
            <div>
              <label className="block text-xs font-medium text-slate-500">Format</label>
              <select className={inputCls + " mt-1"} value={saveAsFormat}
                onChange={(e) => setSaveAsFormat(e.target.value)}>
                <option value="docx">Word (.docx)</option>
                <option value="pdf">PDF (.pdf)</option>
                <option value="txt">Plain text (.txt)</option>
                <option value="odt">OpenDocument (.odt)</option>
                <option value="rtf">Rich text (.rtf)</option>
              </select>
            </div>
            <div>
              <label className="block text-xs font-medium text-slate-500">Section</label>
              <select className={inputCls + " mt-1"} value={saveAsKind}
                onChange={(e) => setSaveAsKind(e.target.value)}>
                {FILE_KINDS.map((k) => <option key={k.key} value={k.key}>{k.label}</option>)}
              </select>
            </div>
          </div>

          <p className="mt-3 text-xs text-slate-400">
            Includes your current edits (even unsaved ones). Non-Word formats are converted by OnlyOffice, with custom fonts preserved.
          </p>

          <div className="mt-5 flex justify-end gap-2">
            <Button onClick={() => setSaveAsOpen(false)}>Cancel</Button>
            <Button variant="primary" disabled={saveAsBusy || !saveAsName.trim()} onClick={submitSaveAs}>
              {saveAsBusy ? <Spinner className="h-3.5 w-3.5" /> : <Icon name="copy" />} Save copy
            </Button>
          </div>
        </div>
      </Modal>
    </div>
  );
}

/* Read-only OnlyOffice viewer (used inside the file preview). */
function OnlyOfficeView({ file, docServerUrl }) {
  const { api } = useAuth();
  const holderId = "oo-view-" + file.id;
  const instRef = useRef(null);
  const [status, setStatus] = useState("loading");
  const [errMsg, setErrMsg] = useState("");

  useEffect(() => {
    let dead = false;
    (async () => {
      try {
        await loadScript(`${docServerUrl}/web-apps/apps/api/documents/api.js`);
        if (dead) return;
        if (!window.DocsAPI) throw new Error("OnlyOffice API did not load");
        const { config } = await api(`/api/files/${file.id}/onlyoffice-config?mode=view`);
        if (dead) return;
        instRef.current = new window.DocsAPI.DocEditor(holderId, {
          ...config,
          width: "100%",
          height: "100%",
          type: window.innerWidth < 768 ? "mobile" : "desktop",
          events: {
            onAppReady: () => setStatus("ready"),
            onError: (e) => { setStatus("error"); setErrMsg((e && e.data) || "Viewer error"); },
          },
        });
      } catch (e) {
        if (!dead) { setStatus("error"); setErrMsg(e.message || String(e)); }
      }
    })();
    return () => { dead = true; try { instRef.current && instRef.current.destroyEditor(); } catch (e) {} };
  }, []);

  return (
    <div className="relative h-full w-full">
      {status === "loading" && <div className="absolute inset-0 flex items-center justify-center"><Spinner className="h-5 w-5 text-brand-500" /></div>}
      {status === "error" && <div className="absolute inset-0 flex items-center justify-center px-4 text-center text-xs text-slate-400">Couldn’t load the viewer: {errMsg}</div>}
      <div id={holderId} className="h-full w-full" />
    </div>
  );
}

/* =========================================================================
 * Version history
 * ========================================================================= */
const VERSION_SOURCE = {
  edit: "Edited", replace: "Replaced", "pre-revert": "Before revert", import: "As imported",
};
function relTime(iso) {
  if (!iso) return "";
  const d = new Date(iso.endsWith("Z") ? iso : iso + "Z"); // server timestamps are UTC
  const diff = (Date.now() - d.getTime()) / 1000;
  if (diff < 60) return "just now";
  if (diff < 3600) return Math.floor(diff / 60) + "m ago";
  if (diff < 86400) return Math.floor(diff / 3600) + "h ago";
  if (diff < 604800) return Math.floor(diff / 86400) + "d ago";
  return d.toLocaleDateString();
}

function VersionHistory({ file, onClose, onRestored }) {
  const { api, token } = useAuth();
  const toast = useToast();
  const [versions, setVersions] = useState(null);
  const [sel, setSel] = useState(null);
  const [preview, setPreview] = useState(null);
  const [confirmRestore, setConfirmRestore] = useState(null);
  const [busy, setBusy] = useState(false);

  const load = useCallback(async () => {
    setVersions(await api(`/api/files/${file.id}/versions`));
  }, [api, file.id]);
  useEffect(() => { load(); }, [load]);

  async function openPreview(v) {
    setSel(v.id);
    setPreview(null);
    if (v.is_text) {
      const res = await fetch(`/api/files/${file.id}/versions/${v.id}/raw`, {
        headers: { Authorization: "Basic " + token },
      });
      setPreview({ type: v.content_type === "text/html" ? "html" : "text", body: await res.text() });
    } else {
      setPreview({ type: "binary" });
    }
  }
  async function restore(v) {
    setBusy(true);
    try {
      const updated = await api(`/api/files/${file.id}/revert/${v.id}`, { method: "POST" });
      toast("Restored earlier version", "success");
      if (onRestored) onRestored(updated);
      load();
    } finally {
      setBusy(false);
    }
  }
  const dlUrl = (v) => `/api/files/${file.id}/versions/${v.id}/raw?token=${encodeURIComponent(token)}&download=true`;

  return (
    <Modal open onClose={onClose} size="xl">
      <div className="flex items-center justify-between border-b border-slate-200 px-5 py-3">
        <h3 className="flex items-center gap-2 text-lg font-semibold text-slate-800">
          <Icon name="clock" className="h-5 w-5 text-slate-400" /> Version history — {file.name}
        </h3>
        <Button variant="ghost" size="icon" onClick={onClose}><Icon name="close" /></Button>
      </div>
      <div className="grid grid-cols-1 sm:grid-cols-[300px_1fr]" style={{ height: "60vh" }}>
        {/* version list */}
        <div className="overflow-y-auto border-r border-slate-100">
          {!versions && <div className="flex h-full items-center justify-center"><Spinner className="h-5 w-5 text-brand-500" /></div>}
          {versions && !versions.length && (
            <p className="px-4 py-6 text-center text-sm text-slate-400">No earlier versions yet. They appear here after you save changes.</p>
          )}
          {versions && versions.map((v) => (
            <button key={v.id} onClick={() => openPreview(v)}
              className={`block w-full border-b border-slate-50 px-4 py-3 text-left hover:bg-slate-50 ${sel === v.id ? "bg-brand-50" : ""}`}>
              <div className="flex items-center justify-between">
                <span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-600">{VERSION_SOURCE[v.source] || v.source}</span>
                <span className="text-xs text-slate-400">{relTime(v.created_at)}</span>
              </div>
              <p className="mt-1 truncate text-xs text-slate-500">{v.preview || "(no text)"}</p>
            </button>
          ))}
        </div>
        {/* preview */}
        <div className="flex flex-col overflow-hidden">
          {!sel && <div className="flex flex-1 items-center justify-center text-sm text-slate-400">Select a version to preview</div>}
          {sel && !preview && <div className="flex flex-1 items-center justify-center"><Spinner className="h-5 w-5 text-brand-500" /></div>}
          {preview && (
            <>
              <div className="flex-1 overflow-y-auto p-5">
                {preview.type === "html" && <div className="rich-editor text-sm text-slate-800" dangerouslySetInnerHTML={{ __html: preview.body }} />}
                {preview.type === "text" && <pre className="mono whitespace-pre-wrap text-sm text-slate-800">{preview.body}</pre>}
                {preview.type === "binary" && (
                  <div className="flex h-full flex-col items-center justify-center gap-3 text-slate-400">
                    <Icon name="doc" className="h-10 w-10" />
                    <a href={dlUrl(versions.find((x) => x.id === sel))}
                      className="rounded-lg bg-slate-100 px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-200">
                      Download this version
                    </a>
                  </div>
                )}
              </div>
              <div className="flex items-center justify-end gap-2 border-t border-slate-200 px-5 py-3">
                <Button variant="primary" disabled={busy}
                  onClick={() => setConfirmRestore(versions.find((x) => x.id === sel))}>
                  <Icon name="restore" /> Restore this version
                </Button>
              </div>
            </>
          )}
        </div>
      </div>
      <Confirm open={!!confirmRestore} title="Restore this version?" confirmLabel="Restore" danger={false}
        message="The current version will be saved to history first, so you can undo this."
        onConfirm={() => restore(confirmRestore)} onClose={() => setConfirmRestore(null)} />
    </Modal>
  );
}

/* =========================================================================
 * Download menu — editable docs offer .docx / PDF; binaries download as-is
 * ========================================================================= */
function DownloadMenu({ file, withLabel }) {
  const { mediaUrl } = useAuth();
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  useEffect(() => {
    const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", h);
    return () => document.removeEventListener("mousedown", h);
  }, []);

  if (!file.is_text) {
    return (
      <a href={mediaUrl(file.id, true)} title="Download"
        className="inline-flex items-center gap-1.5 rounded-lg p-2 text-slate-500 hover:bg-slate-100 hover:text-slate-800">
        <Icon name="download" />{withLabel && <span className="text-sm font-medium">Download</span>}
      </a>
    );
  }
  return (
    <div ref={ref} className="relative">
      <Button size={withLabel ? "sm" : "icon"} variant="ghost" title="Download" onClick={() => setOpen((o) => !o)}>
        <Icon name="download" />{withLabel && "Download"}
      </Button>
      {open && (
        <div className="absolute right-0 z-40 mt-1 w-44 rounded-lg border border-slate-200 bg-white p-1 shadow-lg">
          <a href={mediaUrl(file.id, true, "docx")} onClick={() => setOpen(false)}
            className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50">
            <Icon name="doc" className="h-4 w-4 text-slate-400" /> Word (.docx)
          </a>
          <a href={mediaUrl(file.id, true, "pdf")} onClick={() => setOpen(false)}
            className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50">
            <Icon name="doc" className="h-4 w-4 text-slate-400" /> PDF
          </a>
        </div>
      )}
    </div>
  );
}

/* =========================================================================
 * File preview (read-only)
 * ========================================================================= */
function escapeHtml(s) {
  return (s || "").replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
}

// Print `innerHtml` via a hidden iframe (waits for images to load first).
function printHtml(title, innerHtml) {
  const iframe = document.createElement("iframe");
  iframe.setAttribute("style", "position:fixed;right:0;bottom:0;width:0;height:0;border:0;");
  document.body.appendChild(iframe);
  const doc = iframe.contentWindow.document;
  doc.open();
  doc.write(
    '<!doctype html><html><head><title>' + escapeHtml(title) + '</title><style>' +
    "body{font-family:Georgia,'Times New Roman',serif;color:#111;padding:0;margin:0}" +
    "img{max-width:100%}pre{white-space:pre-wrap;font-family:'SF Mono',Menlo,Consolas,monospace;font-size:12pt}" +
    "@page{margin:0.75in}</style></head><body>" + innerHtml + "</body></html>"
  );
  doc.close();
  const win = iframe.contentWindow;
  const imgs = [...doc.images];
  Promise.all(imgs.map((img) => (img.complete ? Promise.resolve() : new Promise((r) => { img.onload = img.onerror = r; }))))
    .then(() => {
      win.focus();
      win.print();
      setTimeout(() => iframe.remove(), 1500);
    });
}

function filenameFromResponse(res, fallback) {
  const cd = res.headers.get("Content-Disposition") || "";
  const m = /filename="?([^"]+)"?/.exec(cd);
  return m ? m[1] : fallback;
}

function FilePreview({ file, onClose, onEdit }) {
  const { mediaUrl, onlyoffice } = useAuth();
  const toast = useToast();
  const ct = file.content_type || "";
  const url = mediaUrl(file.id);
  const [sharing, setSharing] = useState(false);
  const ooView = file.is_text && onlyoffice && onlyoffice.enabled;

  function print() {
    if (file.is_text && ct === "text/html") {
      printHtml(file.name, file.content || "");
    } else if (file.is_text) {
      printHtml(file.name, "<pre>" + escapeHtml(file.content || "") + "</pre>");
    } else if (ct.startsWith("image/")) {
      printHtml(file.name, '<img src="' + url + '" />');
    } else {
      // PDFs and other binaries: open in a tab and use the browser's print
      window.open(url, "_blank");
    }
  }

  async function share() {
    setSharing(true);
    try {
      const res = await fetch(mediaUrl(file.id, true));
      const blob = await res.blob();
      const filename = filenameFromResponse(res, file.name);
      const shareFile = new File([blob], filename, { type: blob.type || ct || "application/octet-stream" });
      if (navigator.canShare && navigator.canShare({ files: [shareFile] })) {
        await navigator.share({ files: [shareFile], title: file.name });
      } else {
        // No native file sharing here — hand back a download instead.
        const a = document.createElement("a");
        a.href = URL.createObjectURL(blob);
        a.download = filename;
        a.click();
        setTimeout(() => URL.revokeObjectURL(a.href), 1000);
        toast("Sharing files isn’t available in this browser — downloaded instead", "info");
      }
    } catch (e) {
      if (e && e.name === "AbortError") return; // user dismissed the share sheet
      toast("Couldn’t share this file", "error");
    } finally {
      setSharing(false);
    }
  }

  let body;
  if (ooView) {
    body = <OnlyOfficeView file={file} docServerUrl={onlyoffice.url} />;
  } else if (ct.startsWith("image/")) {
    body = <img src={url} alt={file.name} className="mx-auto max-h-[72vh] rounded-lg" />;
  } else if (ct === "application/pdf") {
    body = <iframe src={url} title={file.name} className="h-[74vh] w-full rounded-lg border border-slate-200" />;
  } else if (ct.startsWith("audio/")) {
    body = <div className="py-12"><audio controls src={url} className="w-full" /></div>;
  } else if (ct.startsWith("video/")) {
    body = <video controls src={url} className="mx-auto max-h-[72vh] w-full rounded-lg bg-black" />;
  } else {
    body = (
      <div className="flex flex-col items-center gap-3 py-16 text-slate-400">
        <Icon name="doc" className="h-12 w-12" />
        <p className="text-sm">No inline preview for this file type.</p>
        <a href={mediaUrl(file.id, true)} className="rounded-lg bg-slate-100 px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-200">Download</a>
      </div>
    );
  }

  return (
    <Modal open onClose={onClose} size="xl">
      <div className="flex items-center justify-between border-b border-slate-200 px-5 py-3">
        <h3 className="truncate pr-3 text-lg font-semibold text-slate-800">{file.name}</h3>
        <div className="flex shrink-0 items-center gap-1">
          {file.is_text && onEdit && (
            <Button size="sm" onClick={() => { onClose(); onEdit(file); }}><Icon name="edit" /> Edit</Button>
          )}
          <Button size="sm" onClick={print}><Icon name="printer" /> Print</Button>
          <Button size="sm" onClick={share} disabled={sharing}>
            {sharing ? <Spinner className="h-4 w-4" /> : <Icon name="share" />} Share
          </Button>
          <DownloadMenu file={file} />
          <Button variant="ghost" size="icon" onClick={onClose}><Icon name="close" /></Button>
        </div>
      </div>
      <div className={ooView ? "h-[80vh] bg-slate-50" : "max-h-[80vh] overflow-y-auto bg-slate-50 p-5"}>{body}</div>
    </Modal>
  );
}

/* =========================================================================
 * File section (per kind)
 * ========================================================================= */
function humanSize(n) {
  if (!n) return "";
  if (n < 1024) return n + " B";
  if (n < 1048576) return (n / 1024).toFixed(1) + " KB";
  return (n / 1048576).toFixed(1) + " MB";
}

function FileSection({ song, kind, onChanged, onEdit }) {
  const { api, mediaUrl, ytdlp } = useAuth();
  const toast = useToast();
  const fileInput = useRef(null);
  const replaceInput = useRef(null);
  const replaceTarget = useRef(null);
  const [busy, setBusy] = useState(false);
  const [confirmDel, setConfirmDel] = useState(null);
  const [historyFile, setHistoryFile] = useState(null);
  const [previewFile, setPreviewFile] = useState(null);
  const [renaming, setRenaming] = useState(null);   // file id being renamed
  const [renameVal, setRenameVal] = useState("");
  const renameCancel = useRef(false);
  const [newDocOpen, setNewDocOpen] = useState(false);
  const [newDocName, setNewDocName] = useState("");
  const [dragOver, setDragOver] = useState(false);
  const [dragUpload, setDragUpload] = useState(false); // dragging files from the OS
  const [moveFor, setMoveFor] = useState(null); // file id whose "move to" menu is open
  const [urlOpen, setUrlOpen] = useState(false); // "From URL" download modal
  const [urlVal, setUrlVal] = useState("");
  const [urlBusy, setUrlBusy] = useState(false);
  const [jobs, setJobs] = useState([]);          // download jobs for this kind
  const meta = FILE_KINDS.find((k) => k.key === kind);
  const canDownload = (kind === "audio" || kind === "video") && ytdlp && ytdlp.enabled;

  // Poll download jobs for this section while any are active.
  useEffect(() => {
    if (!canDownload) return;
    let timer, dead = false;
    const seenDone = new Set();
    async function tick() {
      try {
        const all = await api(`/api/songs/${song.id}/downloads`);
        const mine = all.filter((j) => j.kind === kind);
        if (dead) return;
        // when a job finishes, pull in the new file
        let refreshed = false;
        for (const j of mine) {
          if (j.status === "done" && j.file_id && !seenDone.has(j.id)) {
            seenDone.add(j.id);
            if (!refreshed) { onChanged(); refreshed = true; }
          }
        }
        setJobs(mine.filter((j) => j.status !== "done"));
        const active = mine.some((j) => j.status === "queued" || j.status === "running");
        timer = setTimeout(tick, active ? 1200 : 8000);
      } catch (e) {
        if (!dead) timer = setTimeout(tick, 8000);
      }
    }
    tick();
    return () => { dead = true; clearTimeout(timer); };
  }, [song.id, kind, canDownload]);

  async function submitUrl() {
    const url = urlVal.trim();
    if (!url) return;
    setUrlBusy(true);
    try {
      const job = await api(`/api/songs/${song.id}/downloads`, {
        method: "POST", body: { url, kind },
      });
      setJobs((j) => [job, ...j]);
      setUrlOpen(false);
      setUrlVal("");
    } catch (e) {
      toast("Couldn’t start the download — " + (e.message || "try again"), "error");
    } finally {
      setUrlBusy(false);
    }
  }

  async function dismissJob(id) {
    setJobs((j) => j.filter((x) => x.id !== id));
    try { await api(`/api/downloads/${id}`, { method: "DELETE", raw: true }); } catch (e) {}
  }
  const files = song.files.filter((f) => f.kind === kind);

  // Move a file (dropped from another section) into this section's kind.
  async function moveHere(fileId) {
    const f = song.files.find((x) => String(x.id) === String(fileId));
    if (!f || f.kind === kind) return;
    await api(`/api/files/${f.id}`, { method: "PUT", body: { kind } });
    toast(`Moved to ${meta.label}`, "success");
    onChanged();
  }

  // Touch-friendly move: pick a destination section from a menu.
  async function moveTo(f, targetKind) {
    setMoveFor(null);
    if (targetKind === f.kind) return;
    await api(`/api/files/${f.id}`, { method: "PUT", body: { kind: targetKind } });
    toast(`Moved to ${FILE_KINDS.find((k) => k.key === targetKind).label}`, "success");
    onChanged();
  }

  async function saveRename(f) {
    const name = renameVal.trim();
    setRenaming(null);
    if (!name || name === f.name) return;
    await api(`/api/files/${f.id}`, { method: "PUT", body: { name } });
    toast("Renamed", "success");
    onChanged();
  }

  function startReplace(f) { replaceTarget.current = f; replaceInput.current.click(); }
  async function doReplace(e) {
    const f0 = e.target.files[0];
    const target = replaceTarget.current;
    if (!f0 || !target) return;
    setBusy(true);
    const fd = new FormData();
    fd.append("upload", f0);
    try {
      await api(`/api/files/${target.id}/replace`, { method: "POST", body: fd, form: true });
      toast("Replaced — previous version saved to history", "success");
      onChanged();
    } finally {
      setBusy(false);
      e.target.value = "";
      replaceTarget.current = null;
    }
  }

  async function uploadFiles(fileList) {
    const files = [...(fileList || [])];
    if (!files.length) return;
    setBusy(true);
    try {
      for (const f of files) {
        const fd = new FormData();
        fd.append("kind", kind);
        fd.append("upload", f);
        await api(`/api/songs/${song.id}/files`, { method: "POST", body: fd, form: true });
      }
      toast(files.length > 1 ? `Uploaded ${files.length} files` : "Uploaded", "success");
      onChanged();
    } finally {
      setBusy(false);
    }
  }

  async function upload(e) {
    await uploadFiles(e.target.files);
    e.target.value = "";
  }

  async function submitNewDoc() {
    const name = newDocName.trim() || `New ${meta.label} document`;
    setNewDocOpen(false);
    setBusy(true);
    try {
      const doc = await api(`/api/songs/${song.id}/documents`, {
        method: "POST",
        body: { kind, name, content: "" },
      });
      onChanged();
      onEdit(doc);
    } finally {
      setBusy(false);
    }
  }

  async function del(f) {
    await api(`/api/files/${f.id}`, { method: "DELETE", raw: true });
    toast("Deleted");
    onChanged();
  }

  return (
    <div
      className={
        "relative rounded-xl border bg-white transition " +
        (dragOver ? "border-brand-400 ring-2 ring-brand-200" : "border-slate-200")
      }
      onDragOver={(e) => {
        const types = e.dataTransfer.types;
        const isFile = types.includes("Files");
        const isMove = types.includes("application/x-songster-file");
        if (!isFile && !isMove) return;
        e.preventDefault();
        e.dataTransfer.dropEffect = isFile ? "copy" : "move";
        if (!dragOver) setDragOver(true);
        if (isFile !== dragUpload) setDragUpload(isFile);
      }}
      onDragLeave={(e) => {
        if (!e.currentTarget.contains(e.relatedTarget)) { setDragOver(false); setDragUpload(false); }
      }}
      onDrop={(e) => {
        e.preventDefault();
        setDragOver(false);
        setDragUpload(false);
        if (e.dataTransfer.files && e.dataTransfer.files.length) {
          uploadFiles(e.dataTransfer.files);
        } else {
          const id = e.dataTransfer.getData("application/x-songster-file");
          if (id) moveHere(id);
        }
      }}
    >
      {dragOver && dragUpload && (
        <div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-xl bg-brand-50/90 text-sm font-semibold text-brand-700 ring-2 ring-brand-300">
          <Icon name="upload" className="mr-2 h-5 w-5" /> Drop to upload to {meta.label}
        </div>
      )}
      <div className="flex items-center justify-between border-b border-slate-100 px-4 py-2.5">
        <h3 className="flex items-center gap-2 text-sm font-semibold text-slate-700">
          <Icon name={kind === "video" ? "film" : kind === "audio" ? "music" : "doc"} className="h-4 w-4 text-slate-400" />
          {meta.label}
          <span className="rounded-full bg-slate-100 px-1.5 text-xs font-medium text-slate-500">{files.length}</span>
        </h3>
        <div className="flex gap-1.5">
          {canDownload && (
            <Button size="sm" variant="subtle" onClick={() => setUrlOpen(true)}>
              <Icon name="download" className="h-3.5 w-3.5" /> From URL
            </Button>
          )}
          {meta.text && (
            <Button size="sm" variant="subtle" disabled={busy}
              onClick={() => { setNewDocName(`New ${meta.label} document`); setNewDocOpen(true); }}>
              <Icon name="plus" className="h-3.5 w-3.5" /> New doc
            </Button>
          )}
          <Button size="sm" variant="subtle" onClick={() => fileInput.current.click()} disabled={busy}>
            {busy ? <Spinner className="h-3.5 w-3.5" /> : <Icon name="upload" className="h-3.5 w-3.5" />} Upload
          </Button>
          <input ref={fileInput} type="file" className="hidden" onChange={upload}
            accept={kind === "audio" ? "audio/*" : kind === "video" ? "video/*" : undefined} />
        </div>
      </div>

      {/* Active / failed download jobs */}
      {jobs.length > 0 && (
        <div className="divide-y divide-slate-50 border-b border-slate-100">
          {jobs.map((j) => (
            <div key={j.id} className="px-4 py-2.5">
              <div className="flex items-center gap-2">
                <Icon name="download" className="h-4 w-4 shrink-0 text-brand-500" />
                <div className="min-w-0 flex-1">
                  <div className="truncate text-sm font-medium text-slate-700">
                    {j.title || j.url}
                  </div>
                  <div className="text-xs text-slate-400">
                    {j.status === "error" ? <span className="text-red-600">{j.message || "Download failed"}</span>
                      : j.status === "queued" ? "Queued…"
                      : j.message ? `${j.message} ${j.progress || 0}%`
                      : `Downloading ${kind === "audio" ? "MP3" : "MP4"}… ${j.progress || 0}%`}
                  </div>
                </div>
                {(j.status === "error") && (
                  <button onClick={() => dismissJob(j.id)} className="shrink-0 text-slate-400 hover:text-slate-700" title="Dismiss">
                    <Icon name="close" className="h-4 w-4" />
                  </button>
                )}
                {(j.status === "queued" || j.status === "running") && <Spinner className="h-4 w-4 shrink-0 text-brand-500" />}
              </div>
              {(j.status === "running" || j.status === "queued") && (
                <div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-slate-100">
                  <div className="h-full rounded-full bg-brand-500 transition-all" style={{ width: (j.progress || 0) + "%" }} />
                </div>
              )}
            </div>
          ))}
        </div>
      )}

      <div className="divide-y divide-slate-50">
        {!files.length && !jobs.length && (
          <p className="px-4 py-4 text-center text-sm text-slate-400">
            No {meta.label.toLowerCase()} yet.
          </p>
        )}
        {files.map((f) => (
          <div key={f.id} className="px-4 py-2.5"
            draggable={renaming !== f.id}
            onDragStart={(e) => {
              e.dataTransfer.setData("application/x-songster-file", String(f.id));
              e.dataTransfer.effectAllowed = "move";
            }}>
            <div className="flex items-center justify-between gap-2">
              <div className="flex min-w-0 items-center gap-2">
                <Icon name="grip" className="hidden h-4 w-4 shrink-0 cursor-grab text-slate-300 hover:text-slate-500 md:block"
                  title="Drag to another section" />
                <div className="min-w-0">
                {renaming === f.id ? (
                  <input autoFocus value={renameVal}
                    onChange={(e) => setRenameVal(e.target.value)}
                    onKeyDown={(e) => {
                      if (e.key === "Enter") { e.preventDefault(); e.target.blur(); }
                      else if (e.key === "Escape") { renameCancel.current = true; setRenaming(null); }
                    }}
                    onBlur={() => { if (renameCancel.current) { renameCancel.current = false; return; } saveRename(f); }}
                    className="w-full rounded-md border border-brand-300 px-1.5 py-0.5 text-sm text-slate-800 outline-none focus:ring-2 focus:ring-brand-100" />
                ) : (
                  <div className="flex items-center gap-1.5">
                    <button onClick={() => setPreviewFile(f)} title="Preview"
                      className="max-w-full truncate text-left text-sm font-medium text-slate-700 hover:text-brand-600 hover:underline">
                      {f.name}
                    </button>
                    <button title="Rename" onClick={() => { setRenameVal(f.name); setRenaming(f.id); }}
                      className="shrink-0 text-slate-400 hover:text-slate-700">
                      <Icon name="edit" className="h-3.5 w-3.5" />
                    </button>
                  </div>
                )}
                <p className="text-xs text-slate-400">
                  {f.content_type === "text/html"
                    ? "Word document · editable" + (f.edited ? " · edited" : "")
                    : f.is_text ? "Editable document" : (f.content_type || "file")}
                  {f.size ? " · " + humanSize(f.size) : ""}
                </p>
                </div>
              </div>
              <div className="flex shrink-0 items-center gap-0.5">
                {f.is_text && (
                  <Button size="icon" variant="ghost" title="Edit" onClick={() => onEdit(f)}>
                    <Icon name="edit" />
                  </Button>
                )}
                {!f.is_text && (
                  <Button size="icon" variant="ghost" title="Replace file" onClick={() => startReplace(f)}>
                    <Icon name="upload" />
                  </Button>
                )}
                <Button size="icon" variant="ghost" title="Version history" onClick={() => setHistoryFile(f)}>
                  <Icon name="clock" />
                </Button>
                <div className="relative">
                  <Button size="icon" variant="ghost" title="Move to section"
                    onClick={() => setMoveFor(moveFor === f.id ? null : f.id)}>
                    <Icon name="dots" />
                  </Button>
                  {moveFor === f.id && (
                    <>
                      <div className="fixed inset-0 z-20" onClick={() => setMoveFor(null)} />
                      <div className="absolute right-0 z-30 mt-1 w-44 rounded-lg border border-slate-200 bg-white p-1 shadow-lg">
                        <div className="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-slate-400">Move to</div>
                        {FILE_KINDS.filter((k) => k.key !== f.kind).map((k) => (
                          <button key={k.key} onClick={() => moveTo(f, k.key)}
                            className="block w-full rounded-md px-2 py-1.5 text-left text-sm text-slate-700 hover:bg-slate-50">
                            {k.label}
                          </button>
                        ))}
                      </div>
                    </>
                  )}
                </div>
                <DownloadMenu file={f} />
                <Button size="icon" variant="danger" title="Delete" onClick={() => setConfirmDel(f)}>
                  <Icon name="trash" />
                </Button>
              </div>
            </div>
            {kind === "audio" && !f.is_text && (
              <audio controls preload="none" className="mt-2 h-9 w-full" src={mediaUrl(f.id)} />
            )}
            {kind === "video" && !f.is_text && (
              <video controls preload="none" className="mt-2 max-h-72 w-full rounded-lg bg-black" src={mediaUrl(f.id)} />
            )}
          </div>
        ))}
      </div>

      <input ref={replaceInput} type="file" className="hidden" onChange={doReplace} />
      {previewFile && (
        <FilePreview file={previewFile} onClose={() => setPreviewFile(null)} onEdit={onEdit} />
      )}
      {historyFile && (
        <VersionHistory file={historyFile} onClose={() => setHistoryFile(null)}
          onRestored={() => { setHistoryFile(null); onChanged(); }} />
      )}
      <Confirm open={!!confirmDel} title="Delete file?"
        message={`“${confirmDel?.name}” will be permanently removed.`}
        onConfirm={() => del(confirmDel)} onClose={() => setConfirmDel(null)} />

      <Modal open={newDocOpen} onClose={() => setNewDocOpen(false)} size="md">
        <div className="p-6">
          <h3 className="text-lg font-semibold text-slate-800">New {meta.label} document</h3>
          <p className="mt-1 text-sm text-slate-500">Name it, then it opens in the editor.</p>
          <input autoFocus className={inputCls + " mt-3"} value={newDocName}
            onChange={(e) => setNewDocName(e.target.value)}
            onKeyDown={(e) => { if (e.key === "Enter") submitNewDoc(); }} />
          <div className="mt-5 flex justify-end gap-2">
            <Button onClick={() => setNewDocOpen(false)}>Cancel</Button>
            <Button variant="primary" onClick={submitNewDoc}><Icon name="plus" /> Create &amp; open</Button>
          </div>
        </div>
      </Modal>

      <Modal open={urlOpen} onClose={() => setUrlOpen(false)} size="md">
        <div className="p-6">
          <h3 className="text-lg font-semibold text-slate-800">
            Download {kind === "audio" ? "audio" : "video"} from a link
          </h3>
          <p className="mt-1 text-sm text-slate-500">
            Paste a YouTube (or other supported site) URL. It downloads in the background and
            {kind === "audio" ? " is saved as an MP3" : " is saved as an MP4"}.
          </p>
          <input autoFocus className={inputCls + " mt-3"} placeholder="https://www.youtube.com/watch?v=…"
            value={urlVal} onChange={(e) => setUrlVal(e.target.value)}
            onKeyDown={(e) => { if (e.key === "Enter") submitUrl(); }} />
          <div className="mt-5 flex justify-end gap-2">
            <Button onClick={() => setUrlOpen(false)}>Cancel</Button>
            <Button variant="primary" disabled={urlBusy || !urlVal.trim()} onClick={submitUrl}>
              {urlBusy ? <Spinner className="h-4 w-4" /> : <Icon name="download" />} Download
            </Button>
          </div>
        </div>
      </Modal>
    </div>
  );
}

/* =========================================================================
 * Song detail
 * ========================================================================= */
function SongDetail({ songId, onBack, allTags, refreshTags, playlists, refreshPlaylists }) {
  const { api, onlyoffice } = useAuth();
  const toast = useToast();
  const [song, setSong] = useState(null);
  const [form, setForm] = useState(null);
  const [dirty, setDirty] = useState(false);
  const [saving, setSaving] = useState(false);
  const [editingDoc, setEditingDoc] = useState(null);
  const [confirmDel, setConfirmDel] = useState(false);
  const [showPlaylists, setShowPlaylists] = useState(false);

  const load = useCallback(async () => {
    const s = await api(`/api/songs/${songId}`);
    setSong(s);
    setForm({
      title: s.title, composer: s.composer, publication: s.publication,
      text_source: s.text_source, notes: s.notes,
      tag_ids: s.tags.map((t) => t.id),
    });
    setDirty(false);
  }, [api, songId]);

  useEffect(() => { load(); }, [load]);

  function update(patch) {
    setForm((f) => ({ ...f, ...patch }));
    setDirty(true);
  }

  async function save() {
    setSaving(true);
    try {
      await api(`/api/songs/${songId}`, { method: "PUT", body: form });
      toast("Song saved", "success");
      await load();
    } finally {
      setSaving(false);
    }
  }

  async function del() {
    await api(`/api/songs/${songId}`, { method: "DELETE", raw: true });
    toast("Song deleted");
    onBack();
  }

  async function addToPlaylist(pl) {
    await api(`/api/playlists/${pl.id}/songs/${songId}`, { method: "POST" });
    toast(`Added to “${pl.name}”`, "success");
    refreshPlaylists();
    setShowPlaylists(false);
  }

  if (!song || !form) {
    return <div className="flex h-full items-center justify-center"><Spinner className="h-6 w-6 text-brand-500" /></div>;
  }

  return (
    <div className="flex h-full flex-col">
      {/* Header */}
      <div className="flex items-center justify-between gap-2 border-b border-slate-200 bg-white px-4 py-2.5 md:px-6 md:py-3">
        <Button variant="ghost" onClick={onBack}><Icon name="back" /> <span className="hidden sm:inline">Back to songs</span><span className="sm:hidden">Back</span></Button>
        <div className="flex items-center gap-1.5 md:gap-2">
          <div className="relative">
            <Button onClick={() => setShowPlaylists((s) => !s)}><Icon name="list" /> <span className="hidden sm:inline">Add to list</span></Button>
            {showPlaylists && (
              <div className="absolute right-0 z-30 mt-1 max-h-64 w-56 overflow-y-auto rounded-lg border border-slate-200 bg-white p-1 shadow-lg">
                {!playlists.length && <div className="px-3 py-2 text-sm text-slate-400">No playlists yet</div>}
                {playlists.map((p) => (
                  <button key={p.id} onClick={() => addToPlaylist(p)}
                    className="flex w-full items-center justify-between rounded-md px-3 py-1.5 text-left text-sm hover:bg-slate-50">
                    {p.name} <span className="text-xs text-slate-400">{p.item_count}</span>
                  </button>
                ))}
              </div>
            )}
          </div>
          <Button variant="danger" onClick={() => setConfirmDel(true)}><Icon name="trash" /> <span className="hidden sm:inline">Delete</span></Button>
          <Button variant="primary" onClick={save} disabled={!dirty || saving}>
            {saving ? <Spinner className="w-4 h-4" /> : <><Icon name="save" /> {dirty ? "Save" : "Saved"}</>}
          </Button>
        </div>
      </div>

      <div className="pb-safe grid flex-1 grid-cols-1 gap-4 overflow-y-auto p-4 md:gap-6 md:p-6 lg:grid-cols-2">
        {/* Left: metadata */}
        <div className="space-y-4">
          <Field label="Title">
            <input className={inputCls + " text-lg font-semibold"} value={form.title}
              onChange={(e) => update({ title: e.target.value })} />
          </Field>
          <Field label="Composer">
            <input className={inputCls} value={form.composer} onChange={(e) => update({ composer: e.target.value })} />
          </Field>
          <Field label="Publication">
            <input className={inputCls} value={form.publication} onChange={(e) => update({ publication: e.target.value })} />
          </Field>
          <Field label="Text Source">
            <textarea className={inputCls + " h-24 resize-y"} value={form.text_source}
              onChange={(e) => update({ text_source: e.target.value })} />
          </Field>
          <Field label="Notes">
            <textarea className={inputCls + " h-28 resize-y"} value={form.notes}
              onChange={(e) => update({ notes: e.target.value })} />
          </Field>
          <div className="space-y-3 rounded-xl border border-slate-200 bg-white p-4">
            {CATEGORIES.map((cat) => (
              <TagPicker key={cat.key} category={cat.key} label={cat.label} allTags={allTags}
                selectedIds={form.tag_ids}
                onChange={(ids) => update({ tag_ids: ids })}
                api={api} onTagsChanged={refreshTags} />
            ))}
          </div>
        </div>

        {/* Right: files */}
        <div className="space-y-4">
          {FILE_KINDS.map((k) => (
            <FileSection key={k.key} song={song} kind={k.key}
              onChanged={load} onEdit={setEditingDoc} />
          ))}
        </div>
      </div>

      {editingDoc && (
        onlyoffice && onlyoffice.enabled ? (
          <OnlyOfficeEditor file={editingDoc} docServerUrl={onlyoffice.url}
            onClose={() => setEditingDoc(null)} onSaved={() => { setEditingDoc(null); load(); }} />
        ) : (
          <Modal open onClose={() => setEditingDoc(null)} size="md">
            <div className="p-6">
              <h3 className="text-lg font-semibold text-slate-800">Editor unavailable</h3>
              <p className="mt-2 text-sm text-slate-500">The document editor (OnlyOffice) isn’t running. Start it with <code className="rounded bg-slate-100 px-1">docker compose up -d</code>, or download the file to edit it.</p>
              <div className="mt-5 flex justify-end"><Button onClick={() => setEditingDoc(null)}>Close</Button></div>
            </div>
          </Modal>
        )
      )}
      <Confirm open={confirmDel} title="Delete song?"
        message={`“${song.title}” and all its documents & files will be permanently deleted.`}
        onConfirm={del} onClose={() => setConfirmDel(false)} />
    </div>
  );
}

/* =========================================================================
 * Playlists
 * ========================================================================= */
function PlaylistsView({ onOpen, playlists, refreshPlaylists }) {
  const { api } = useAuth();
  const toast = useToast();
  const [name, setName] = useState("");
  const [confirmDel, setConfirmDel] = useState(null);

  async function create(e) {
    e.preventDefault();
    if (!name.trim()) return;
    const pl = await api(`/api/playlists`, { method: "POST", body: { name: name.trim() } });
    setName("");
    refreshPlaylists();
    onOpen(pl.id);
  }
  async function del(pl) {
    await api(`/api/playlists/${pl.id}`, { method: "DELETE", raw: true });
    toast("Playlist deleted");
    refreshPlaylists();
  }

  return (
    <div className="flex h-full flex-col">
      <div className="border-b border-slate-200 bg-white px-6 py-4">
        <h1 className="text-xl font-bold text-slate-800">Playlists</h1>
        <form onSubmit={create} className="mt-3 flex gap-2">
          <input className={inputCls + " max-w-sm"} placeholder="New playlist name…"
            value={name} onChange={(e) => setName(e.target.value)} />
          <Button variant="primary" type="submit"><Icon name="plus" /> Create</Button>
        </form>
      </div>
      <div className="flex-1 overflow-y-auto p-6">
        {!playlists.length ? (
          <div className="flex h-full flex-col items-center justify-center text-slate-400">
            <Icon name="list" className="h-10 w-10" />
            <p className="mt-2 text-sm">No playlists yet — create one above.</p>
          </div>
        ) : (
          <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
            {playlists.map((p) => (
              <div key={p.id} onClick={() => onOpen(p.id)}
                className="group cursor-pointer rounded-xl border border-slate-200 bg-white p-4 transition hover:border-brand-300 hover:shadow-md">
                <div className="flex items-start justify-between">
                  <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-brand-50 text-brand-600">
                    <Icon name="list" className="h-5 w-5" />
                  </div>
                  <Button size="icon" variant="danger" className="opacity-0 group-hover:opacity-100"
                    onClick={(e) => { e.stopPropagation(); setConfirmDel(p); }}>
                    <Icon name="trash" />
                  </Button>
                </div>
                <h3 className="mt-3 font-semibold text-slate-800">{p.name}</h3>
                <p className="text-sm text-slate-500">{p.item_count} song{p.item_count === 1 ? "" : "s"}</p>
              </div>
            ))}
          </div>
        )}
      </div>
      <Confirm open={!!confirmDel} title="Delete playlist?"
        message={`“${confirmDel?.name}” will be removed. Songs are not affected.`}
        onConfirm={() => del(confirmDel)} onClose={() => setConfirmDel(null)} />
    </div>
  );
}

function PlaylistDetail({ playlistId, onBack, onOpenSong, allTags = [], onGoSonglists, refreshPlaylists }) {
  const { api } = useAuth();
  const toast = useToast();
  const [pl, setPl] = useState(null);
  const [adding, setAdding] = useState(false);
  const [genOpen, setGenOpen] = useState(false);
  const [genName, setGenName] = useState("");
  const [genBusy, setGenBusy] = useState(false);
  const [renaming, setRenaming] = useState(false);
  const [nameVal, setNameVal] = useState("");
  const renameCancel = useRef(false);
  const [q, setQ] = useState("");
  const [selectedTags, setSelectedTags] = useState([]);
  const [letter, setLetter] = useState("");
  const [results, setResults] = useState([]);

  const load = useCallback(async () => {
    setPl(await api(`/api/playlists/${playlistId}`));
    refreshPlaylists && refreshPlaylists();  // keep the Shell's playlist list (name/count) in sync
  }, [api, playlistId, refreshPlaylists]);
  useEffect(() => { load(); }, [load]);

  function toggleTag(id) {
    setSelectedTags((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
  }
  const hasFilters = q || selectedTags.length > 0 || letter;
  function clearFilters() { setQ(""); setSelectedTags([]); setLetter(""); }

  useEffect(() => {
    if (!adding) return;
    const t = setTimeout(async () => {
      const params = new URLSearchParams({ page_size: "50" });
      if (q) params.set("q", q);
      if (letter) params.set("letter", letter);
      if (selectedTags.length) params.set("tag_ids", selectedTags.join(","));
      const res = await api(`/api/songs?${params}`);
      setResults(res.items);
    }, 200);
    return () => clearTimeout(t);
  }, [q, letter, selectedTags, adding, api]);

  async function saveOrder(items) {
    await api(`/api/playlists/${playlistId}`, {
      method: "PUT",
      body: { song_ids: items.map((i) => i.song.id) },
    });
    load();
  }
  function move(idx, dir) {
    const items = [...pl.items];
    const j = idx + dir;
    if (j < 0 || j >= items.length) return;
    [items[idx], items[j]] = [items[j], items[idx]];
    setPl({ ...pl, items });
    saveOrder(items);
  }
  async function remove(item) {
    const items = pl.items.filter((i) => i.id !== item.id);
    setPl({ ...pl, items });
    await saveOrder(items);
  }
  async function add(song) {
    await api(`/api/playlists/${playlistId}/songs/${song.id}`, { method: "POST" });
    toast("Added", "success");
    load();
  }
  async function saveName() {
    const name = nameVal.trim();
    setRenaming(false);
    if (!name || name === pl.name) return;
    await api(`/api/playlists/${playlistId}`, { method: "PUT", body: { name } });
    toast("Renamed", "success");
    load();
  }
  async function generate() {
    const name = genName.trim();
    if (!name) return;
    setGenBusy(true);
    try {
      const f = await api(`/api/playlists/${playlistId}/songlist`, { method: "POST", body: { name } });
      setGenOpen(false);
      toast(`Created “${f.name}” in SongLists`, "success");
      onGoSonglists && onGoSonglists();
    } catch (e) {
      toast(e.message || "Couldn’t generate the SongList", "error");
    } finally {
      setGenBusy(false);
    }
  }

  if (!pl) return <div className="flex h-full items-center justify-center"><Spinner className="h-6 w-6 text-brand-500" /></div>;
  const inList = new Set(pl.items.map((i) => i.song.id));

  return (
    <div className="flex h-full flex-col">
      <div className="flex items-center justify-between gap-2 border-b border-slate-200 bg-white px-4 py-2.5 md:px-6 md:py-3">
        <Button variant="ghost" onClick={onBack}><Icon name="back" /> <span className="hidden sm:inline">Playlists</span></Button>
        <div className="flex items-center gap-1.5 md:gap-2">
          <Button disabled={!pl.items.length}
            onClick={() => { setGenName(pl.name || "SongList"); setGenOpen(true); }}>
            <Icon name="layers" /> <span className="hidden sm:inline">Generate SongList</span><span className="sm:hidden">Generate</span>
          </Button>
          <Button variant="primary" onClick={() => setAdding(true)}><Icon name="plus" /> <span className="hidden sm:inline">Add songs</span><span className="sm:hidden">Add</span></Button>
        </div>
      </div>
      <div className="border-b border-slate-200 bg-white px-4 py-3 md:px-6 md:py-4">
        {renaming ? (
          <input autoFocus value={nameVal}
            onChange={(e) => setNameVal(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Enter") { e.preventDefault(); e.target.blur(); }
              else if (e.key === "Escape") { renameCancel.current = true; setRenaming(false); }
            }}
            onBlur={() => { if (renameCancel.current) { renameCancel.current = false; return; } saveName(); }}
            className="w-full max-w-md rounded-md border border-brand-300 px-2 py-0.5 text-xl font-bold text-slate-800 outline-none focus:ring-2 focus:ring-brand-100" />
        ) : (
          <div className="flex items-center gap-2">
            <h1 className="truncate text-xl font-bold text-slate-800 md:text-2xl">{pl.name}</h1>
            <button title="Rename playlist" onClick={() => { setNameVal(pl.name); setRenaming(true); }}
              className="shrink-0 text-slate-400 hover:text-slate-700"><Icon name="edit" className="h-4 w-4" /></button>
          </div>
        )}
        <p className="text-sm text-slate-500">{pl.items.length} song{pl.items.length === 1 ? "" : "s"}</p>
      </div>
      <div className="flex-1 overflow-y-auto p-6">
        {!pl.items.length ? (
          <div className="flex h-full flex-col items-center justify-center text-slate-400">
            <Icon name="music" className="h-10 w-10" />
            <p className="mt-2 text-sm">Empty playlist — add songs.</p>
          </div>
        ) : (
          <div className="mx-auto max-w-3xl divide-y divide-slate-100 overflow-hidden rounded-xl border border-slate-200 bg-white">
            {pl.items.map((item, idx) => (
              <div key={item.id} className="flex items-center gap-3 px-4 py-2.5 hover:bg-slate-50">
                <span className="w-6 shrink-0 text-center text-sm font-semibold text-slate-400">{idx + 1}</span>
                <div className="min-w-0 flex-1 cursor-pointer" onClick={() => onOpenSong(item.song.id)}>
                  <p className="truncate font-medium text-slate-800">{item.song.title}</p>
                  {item.song.composer && <p className="truncate text-xs text-slate-500">{item.song.composer}</p>}
                  {item.song.tags && item.song.tags.length > 0 && (
                    <div className="mt-1 flex flex-wrap gap-1">
                      {item.song.tags.map((t) => <TagChip key={t.id} tag={t} />)}
                    </div>
                  )}
                </div>
                <div className="flex shrink-0 items-center gap-0.5">
                  <Button size="icon" variant="ghost" disabled={idx === 0} onClick={() => move(idx, -1)}><Icon name="arrowUp" /></Button>
                  <Button size="icon" variant="ghost" disabled={idx === pl.items.length - 1} onClick={() => move(idx, 1)}><Icon name="arrowDown" /></Button>
                  <Button size="icon" variant="danger" onClick={() => remove(item)}><Icon name="trash" /></Button>
                </div>
              </div>
            ))}
          </div>
        )}
      </div>

      <Modal open={adding} onClose={() => setAdding(false)} size="xl">
        <div className="space-y-3 border-b border-slate-200 px-5 py-3">
          <div className="flex items-center gap-2">
            <div className="relative flex-1">
              <Icon name="search" className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
              <input autoFocus className={inputCls + " pl-9"} placeholder="Search songs to add…"
                value={q} onChange={(e) => setQ(e.target.value)} />
            </div>
            <Button onClick={clearFilters} disabled={!hasFilters}><Icon name="close" /> Clear</Button>
          </div>
          <TagFilters allTags={allTags} selectedTags={selectedTags} toggleTag={toggleTag}
            letter={letter} setLetter={setLetter} columnHeight="h-32" />
        </div>
        <div className="max-h-[40vh] overflow-y-auto p-2">
          {!results.length && <p className="px-3 py-6 text-center text-sm text-slate-400">No songs match these filters.</p>}
          {results.map((s) => (
            <div key={s.id} className="flex items-center justify-between gap-3 rounded-lg px-3 py-2 hover:bg-slate-50">
              <div className="min-w-0">
                <p className="truncate text-sm font-medium text-slate-800">{s.title}</p>
                {s.composer && <p className="truncate text-xs text-slate-500">{s.composer}</p>}
                {s.tags && s.tags.length > 0 && (
                  <div className="mt-1 flex flex-wrap gap-1">
                    {s.tags.map((t) => <TagChip key={t.id} tag={t} />)}
                  </div>
                )}
              </div>
              {inList.has(s.id) ? (
                <span className="shrink-0 text-xs font-medium text-emerald-600">In list</span>
              ) : (
                <Button size="sm" variant="subtle" onClick={() => add(s)}><Icon name="plus" className="h-3.5 w-3.5" /> Add</Button>
              )}
            </div>
          ))}
        </div>
      </Modal>

      <Modal open={genOpen} onClose={() => setGenOpen(false)} size="md">
        <div className="p-6">
          <h3 className="text-lg font-semibold text-slate-800">Generate a SongList</h3>
          <p className="mt-1 text-sm text-slate-500">
            Combines each song’s chord document — in this playlist’s order — into one document, saved under <b>SongLists</b>. Songs without a chord doc are skipped.
          </p>
          <label className="mt-4 block text-xs font-medium text-slate-500">Name</label>
          <input autoFocus className={inputCls + " mt-1"} value={genName}
            onChange={(e) => setGenName(e.target.value)}
            onKeyDown={(e) => { if (e.key === "Enter") generate(); }} />
          <div className="mt-5 flex justify-end gap-2">
            <Button onClick={() => setGenOpen(false)}>Cancel</Button>
            <Button variant="primary" disabled={genBusy || !genName.trim()} onClick={generate}>
              {genBusy ? <Spinner className="h-3.5 w-3.5" /> : <Icon name="layers" />} Generate
            </Button>
          </div>
        </div>
      </Modal>
    </div>
  );
}

/* =========================================================================
 * SongLists — setlist documents generated from playlists
 * ========================================================================= */
function SongListsView() {
  const { api, onlyoffice } = useAuth();
  const toast = useToast();
  const [lists, setLists] = useState(null);
  const [editingDoc, setEditingDoc] = useState(null);
  const [previewFile, setPreviewFile] = useState(null);
  const [renaming, setRenaming] = useState(null);
  const [renameVal, setRenameVal] = useState("");
  const renameCancel = useRef(false);
  const [confirmDel, setConfirmDel] = useState(null);

  const load = useCallback(async () => { setLists(await api(`/api/songlists`)); }, [api]);
  useEffect(() => { load(); }, [load]);

  async function saveRename(f) {
    const name = renameVal.trim();
    setRenaming(null);
    if (!name || name === f.name) return;
    await api(`/api/files/${f.id}`, { method: "PUT", body: { name } });
    toast("Renamed", "success");
    load();
  }
  async function del(f) {
    await api(`/api/files/${f.id}`, { method: "DELETE", raw: true });
    toast("Deleted");
    load();
  }

  return (
    <div className="flex h-full flex-col">
      <div className="border-b border-slate-200 bg-white px-4 py-3 md:px-6 md:py-4">
        <h1 className="text-xl font-bold text-slate-900 md:text-2xl">SongLists</h1>
        <p className="text-sm text-slate-600 md:text-base">Setlists generated from your playlists</p>
      </div>
      <div className="pb-safe flex-1 overflow-y-auto p-4 md:p-6">
        {lists === null ? (
          <div className="flex h-full items-center justify-center"><Spinner className="h-6 w-6 text-brand-500" /></div>
        ) : !lists.length ? (
          <div className="flex h-full flex-col items-center justify-center text-slate-400">
            <Icon name="layers" className="h-10 w-10" />
            <p className="mt-2 text-sm">No song lists yet.</p>
            <p className="text-xs">Open a playlist and use <b>Generate SongList</b>.</p>
          </div>
        ) : (
          <div className="mx-auto max-w-3xl divide-y divide-slate-100 rounded-xl border border-slate-200 bg-white">
            {lists.map((f) => (
              <div key={f.id} className="flex items-center gap-3 px-4 py-3 first:rounded-t-xl last:rounded-b-xl hover:bg-slate-50">
                <Icon name="layers" className="h-5 w-5 shrink-0 text-slate-400" />
                <div className="min-w-0 flex-1">
                  {renaming === f.id ? (
                    <input autoFocus value={renameVal}
                      onChange={(e) => setRenameVal(e.target.value)}
                      onKeyDown={(e) => {
                        if (e.key === "Enter") { e.preventDefault(); e.target.blur(); }
                        else if (e.key === "Escape") { renameCancel.current = true; setRenaming(null); }
                      }}
                      onBlur={() => { if (renameCancel.current) { renameCancel.current = false; return; } saveRename(f); }}
                      className="w-full rounded-md border border-brand-300 px-1.5 py-0.5 text-sm text-slate-800 outline-none focus:ring-2 focus:ring-brand-100" />
                  ) : (
                    <div className="flex items-center gap-1.5">
                      <button onClick={() => setPreviewFile(f)} title="Preview"
                        className="max-w-full truncate text-left font-medium text-slate-800 hover:text-brand-600 hover:underline">{f.name}</button>
                      <button title="Rename" onClick={() => { setRenameVal(f.name); setRenaming(f.id); }}
                        className="shrink-0 text-slate-400 hover:text-slate-700"><Icon name="edit" className="h-3.5 w-3.5" /></button>
                    </div>
                  )}
                  <p className="text-xs text-slate-400">{humanSize(f.size)}{f.edited ? " · edited" : ""}</p>
                </div>
                <div className="flex shrink-0 items-center gap-0.5">
                  <Button size="icon" variant="ghost" title="Edit" onClick={() => setEditingDoc(f)}><Icon name="edit" /></Button>
                  <DownloadMenu file={f} />
                  <Button size="icon" variant="danger" title="Delete" onClick={() => setConfirmDel(f)}><Icon name="trash" /></Button>
                </div>
              </div>
            ))}
          </div>
        )}
      </div>

      {previewFile && <FilePreview file={previewFile} onClose={() => setPreviewFile(null)} onEdit={setEditingDoc} />}
      {editingDoc && (
        onlyoffice && onlyoffice.enabled ? (
          <OnlyOfficeEditor file={editingDoc} docServerUrl={onlyoffice.url}
            onClose={() => setEditingDoc(null)} onSaved={() => { setEditingDoc(null); load(); }} />
        ) : (
          <Modal open onClose={() => setEditingDoc(null)} size="md">
            <div className="p-6">
              <h3 className="text-lg font-semibold text-slate-800">Editor unavailable</h3>
              <p className="mt-2 text-sm text-slate-500">OnlyOffice isn’t running. Download the file to edit it.</p>
              <div className="mt-5 flex justify-end"><Button onClick={() => setEditingDoc(null)}>Close</Button></div>
            </div>
          </Modal>
        )
      )}
      <Confirm open={!!confirmDel} title="Delete song list?"
        message={`“${confirmDel?.name}” will be permanently removed.`}
        onConfirm={() => del(confirmDel)} onClose={() => setConfirmDel(null)} />
    </div>
  );
}

/* =========================================================================
 * Tags management
 * ========================================================================= */
function TagsView({ allTags, refreshTags }) {
  const { api } = useAuth();
  const toast = useToast();
  const [confirmDel, setConfirmDel] = useState(null);
  const [drafts, setDrafts] = useState({});
  const [lists, setLists] = useState({});          // category -> ordered tag array (local, for smooth DnD)
  const [dragOver, setDragOver] = useState(null);  // { cat, index }
  const dragItem = useRef(null);                   // { cat, index }

  useEffect(() => {
    const next = {};
    CATEGORIES.forEach((c) => (next[c.key] = allTags.filter((t) => t.category === c.key)));
    setLists(next);
  }, [allTags]);

  async function add(cat) {
    const name = (drafts[cat] || "").trim();
    if (!name) return;
    await api(`/api/tags`, { method: "POST", body: { name, category: cat } });
    setDrafts((d) => ({ ...d, [cat]: "" }));
    refreshTags();
  }
  async function del(tag) {
    await api(`/api/tags/${tag.id}`, { method: "DELETE", raw: true });
    toast("Tag deleted");
    refreshTags();
  }

  function persistOrder(cat, list) {
    api(`/api/tags/order`, { method: "PUT", body: { category: cat, ordered_ids: list.map((t) => t.id) } })
      .then(refreshTags)
      .catch((e) => { toast(e.message || "Couldn't save order", "error"); refreshTags(); });
  }

  function onDrop(cat, index) {
    const from = dragItem.current;
    dragItem.current = null;
    setDragOver(null);
    if (!from || from.cat !== cat || from.index === index) return;
    const list = [...(lists[cat] || [])];
    const [moved] = list.splice(from.index, 1);
    list.splice(index > from.index ? index - 1 : index, 0, moved);
    setLists((l) => ({ ...l, [cat]: list }));
    persistOrder(cat, list);
  }

  return (
    <div className="flex h-full flex-col">
      <div className="border-b border-slate-200 bg-white px-6 py-4">
        <h1 className="text-xl font-bold text-slate-800">Tags</h1>
        <p className="text-sm text-slate-500">
          Manage the Themes, Holidays and Grades used to organize songs. Drag <Icon name="grip" className="inline h-3.5 w-3.5 -mt-0.5 text-slate-400" /> to reorder — the order shows everywhere you tag &amp; filter.
        </p>
      </div>
      <div className="flex-1 overflow-y-auto p-6">
        <div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
          {CATEGORIES.map((cat) => {
            const tags = lists[cat.key] || [];
            return (
              <div key={cat.key} className="rounded-xl border border-slate-200 bg-white">
                <div className="flex items-center justify-between border-b border-slate-100 px-4 py-2.5">
                  <h3 className="font-semibold text-slate-700">{cat.label}</h3>
                  <span className="rounded-full bg-slate-100 px-2 text-xs font-medium text-slate-500">{tags.length}</span>
                </div>
                <div className="flex gap-2 border-b border-slate-100 p-3">
                  <input className={inputCls} placeholder={`Add ${cat.label.slice(0, -1).toLowerCase()}…`}
                    value={drafts[cat.key] || ""}
                    onChange={(e) => setDrafts((d) => ({ ...d, [cat.key]: e.target.value }))}
                    onKeyDown={(e) => e.key === "Enter" && add(cat.key)} />
                  <Button variant="subtle" onClick={() => add(cat.key)}><Icon name="plus" /></Button>
                </div>
                <div className="max-h-[55vh] overflow-y-auto py-1"
                  onDragOver={(e) => { e.preventDefault(); if (dragItem.current?.cat === cat.key) setDragOver({ cat: cat.key, index: tags.length }); }}
                  onDrop={() => onDrop(cat.key, tags.length)}>
                  {tags.map((t, index) => {
                    const isDragging = dragItem.current?.cat === cat.key && dragItem.current?.index === index;
                    const showLine = dragOver && dragOver.cat === cat.key && dragOver.index === index;
                    return (
                      <div key={t.id}
                        draggable
                        onDragStart={(e) => { dragItem.current = { cat: cat.key, index }; e.dataTransfer.effectAllowed = "move"; }}
                        onDragEnd={() => { dragItem.current = null; setDragOver(null); }}
                        onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); if (dragItem.current?.cat === cat.key) setDragOver({ cat: cat.key, index }); }}
                        onDrop={(e) => { e.stopPropagation(); onDrop(cat.key, index); }}
                        className={`group flex items-center gap-2 px-2 py-2 ${showLine ? "border-t-2 border-brand-400" : "border-t-2 border-transparent"} ${isDragging ? "opacity-40" : "hover:bg-slate-50"}`}>
                        <span className="cursor-grab text-slate-300 group-hover:text-slate-400 active:cursor-grabbing">
                          <Icon name="grip" className="h-4 w-4" />
                        </span>
                        <span className="flex-1 select-none text-sm text-slate-700">{t.name}</span>
                        <Button size="icon" variant="danger" onClick={() => setConfirmDel(t)}><Icon name="trash" /></Button>
                      </div>
                    );
                  })}
                  {!tags.length && <p className="px-3 py-3 text-center text-sm text-slate-400">No tags yet</p>}
                </div>
              </div>
            );
          })}
        </div>
      </div>
      <Confirm open={!!confirmDel} title="Delete tag?"
        message={`“${confirmDel?.name}” will be removed from all songs.`}
        onConfirm={() => del(confirmDel)} onClose={() => setConfirmDel(null)} />
    </div>
  );
}

/* =========================================================================
 * App shell
 * ========================================================================= */
function NavItem({ icon, label, active, onClick }) {
  return (
    <button onClick={onClick}
      className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition ${
        active ? "bg-brand-600 text-white shadow-sm shadow-brand-600/20" : "text-slate-600 hover:bg-slate-100"
      }`}>
      <Icon name={icon} className="h-4 w-4" /> {label}
    </button>
  );
}

function Shell({ username, onLogout }) {
  const { api } = useAuth();
  const [route, setRoute] = useState({ name: "songs" });
  const [allTags, setAllTags] = useState([]);
  const [playlists, setPlaylists] = useState([]);

  const refreshTags = useCallback(async () => {
    try { setAllTags(await api(`/api/tags`)); } catch {}
  }, [api]);
  const refreshPlaylists = useCallback(async () => {
    try { setPlaylists(await api(`/api/playlists`)); } catch {}
  }, [api]);

  useEffect(() => { refreshTags(); refreshPlaylists(); }, [refreshTags, refreshPlaylists]);

  const [drawer, setDrawer] = useState(false);
  const nav = (name) => setRoute({ name });

  return (
    <div className="flex h-full">
      {/* Desktop sidebar */}
      <aside className="hidden w-56 shrink-0 flex-col border-r border-slate-200 bg-white md:flex">
        <SidebarContent route={route} nav={nav} username={username} onLogout={onLogout} />
      </aside>

      {/* Mobile drawer */}
      {drawer && (
        <div className="fixed inset-0 z-40 md:hidden" onClick={() => setDrawer(false)}>
          <div className="absolute inset-0 bg-slate-900/50" />
          <aside className="pt-safe absolute inset-y-0 left-0 flex w-64 max-w-[82%] flex-col bg-white shadow-xl"
            onClick={(e) => e.stopPropagation()}>
            <SidebarContent route={route} nav={nav} username={username} onLogout={onLogout}
              onNavigate={() => setDrawer(false)} />
          </aside>
        </div>
      )}

      <div className="flex min-w-0 flex-1 flex-col">
        {/* Mobile top bar */}
        <div className="pt-safe flex items-center gap-2 border-b border-slate-200 bg-white px-2 py-2 md:hidden">
          <button onClick={() => setDrawer(true)} aria-label="Menu"
            className="rounded-lg p-2 text-slate-600 hover:bg-slate-100">
            <Icon name="menu" className="h-5 w-5" />
          </button>
          <div className="flex items-center gap-2">
            <div className="flex h-7 w-7 items-center justify-center rounded-lg bg-brand-600 text-white">
              <Icon name="music" className="h-4 w-4" />
            </div>
            <span className="text-base font-bold tracking-tight text-slate-800">Songster</span>
          </div>
        </div>

        {/* Main */}
        <main className="min-h-0 min-w-0 flex-1">
          {route.name === "songs" && (
            <SongsView onOpen={(id) => setRoute({ name: "song", id })} allTags={allTags} refreshTags={refreshTags} />
          )}
          {route.name === "song" && (
            <SongDetail songId={route.id} onBack={() => nav("songs")} allTags={allTags} refreshTags={refreshTags}
              playlists={playlists} refreshPlaylists={refreshPlaylists} />
          )}
          {route.name === "playlists" && (
            <PlaylistsView onOpen={(id) => setRoute({ name: "playlist", id })} playlists={playlists} refreshPlaylists={refreshPlaylists} />
          )}
          {route.name === "playlist" && (
            <PlaylistDetail playlistId={route.id} onBack={() => nav("playlists")}
              onOpenSong={(id) => setRoute({ name: "song", id })} allTags={allTags}
              onGoSonglists={() => nav("songlists")} refreshPlaylists={refreshPlaylists} />
          )}
          {route.name === "songlists" && <SongListsView />}
          {route.name === "tags" && <TagsView allTags={allTags} refreshTags={refreshTags} />}
        </main>
      </div>
    </div>
  );
}

function SidebarContent({ route, nav, username, onLogout, onNavigate }) {
  const go = (name) => { nav(name); onNavigate && onNavigate(); };
  return (
    <>
      <div className="flex items-center gap-2.5 px-4 py-4">
        <div className="flex h-9 w-9 items-center justify-center rounded-xl bg-brand-600 text-white shadow-sm shadow-brand-600/30">
          <Icon name="music" className="h-5 w-5" />
        </div>
        <span className="text-lg font-bold tracking-tight text-slate-800">Songster</span>
      </div>
      <nav className="flex-1 space-y-1 px-3">
        <NavItem icon="music" label="Songs" active={route.name === "songs" || route.name === "song"}
          onClick={() => go("songs")} />
        <NavItem icon="list" label="Playlists" active={route.name === "playlists" || route.name === "playlist"}
          onClick={() => go("playlists")} />
        <NavItem icon="layers" label="SongLists" active={route.name === "songlists"} onClick={() => go("songlists")} />
        <NavItem icon="tag" label="Tags" active={route.name === "tags"} onClick={() => go("tags")} />
      </nav>
      <div className="pb-safe border-t border-slate-100 p-3">
        <div className="flex items-center gap-2 px-1 py-2">
          <div className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-200 text-xs font-bold text-slate-600">
            {username.slice(0, 2).toUpperCase()}
          </div>
          <span className="flex-1 truncate text-sm font-medium text-slate-700">{username}</span>
        </div>
        <ThemeToggle />
        <Button variant="ghost" className="w-full justify-start" onClick={onLogout}>
          <Icon name="logout" /> Sign out
        </Button>
      </div>
    </>
  );
}

/* =========================================================================
 * Root
 * ========================================================================= */
function App() {
  const [token, setToken] = useState(() => localStorage.getItem("songster_token") || "");
  const [username, setUsername] = useState(() => localStorage.getItem("songster_user") || "");

  const logout = useCallback(() => {
    localStorage.removeItem("songster_token");
    localStorage.removeItem("songster_user");
    setToken("");
    setUsername("");
  }, []);

  const request = useCallback(async (path, opts = {}) => {
    const headers = { Authorization: "Basic " + token, ...(opts.headers || {}) };
    let body = opts.body;
    if (body !== undefined && !opts.form) {
      headers["Content-Type"] = "application/json";
      body = JSON.stringify(body);
    }
    const res = await fetch(path, { method: opts.method || "GET", headers, body });
    if (res.status === 401) { logout(); throw new Error("Session expired"); }
    if (!res.ok) {
      let detail = res.statusText;
      try { detail = (await res.json()).detail || detail; } catch {}
      throw new Error(detail);
    }
    if (opts.raw || res.status === 204) return null;
    return res.json();
  }, [token, logout]);

  const mediaUrl = useCallback((fileId, download, format) =>
    `/api/files/${fileId}/raw?token=${encodeURIComponent(token)}` +
    (download ? "&download=true" : "") + (format ? "&format=" + format : ""),
  [token]);

  const [appConfig, setAppConfig] = useState(null);
  useEffect(() => {
    if (!token) { setAppConfig(null); return; }
    request("/api/config").then(setAppConfig).catch(() => setAppConfig({ onlyoffice: { enabled: false } }));
  }, [token, request]);

  function onLogin(tok, name) {
    localStorage.setItem("songster_token", tok);
    localStorage.setItem("songster_user", name);
    setToken(tok);
    setUsername(name);
  }

  if (!token) return <ToastHost><Login onLogin={onLogin} /></ToastHost>;

  return (
    <AuthContext.Provider value={{ token, username, api: request, mediaUrl, logout, onlyoffice: appConfig && appConfig.onlyoffice, ytdlp: appConfig && appConfig.ytdlp }}>
      <ToastHost>
        <Shell username={username} onLogout={logout} />
      </ToastHost>
    </AuthContext.Provider>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
