"use client";

import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import { Check, Package, Loader2, GripVertical, MoreVertical, Trash2, Search, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
  DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { hasQuantity, formatQuantity } from "@/lib/fractions";
import { guessAisle, GROCERY_CATEGORIES } from "@/lib/grocery-categories";
import {
  DndContext,
  DragEndEvent,
  DragOverEvent,
  DragStartEvent,
  PointerSensor,
  useSensor,
  useSensors,
  closestCenter,
} from "@dnd-kit/core";
import {
  SortableContext,
  useSortable,
  verticalListSortingStrategy,
  arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";

type Item = {
  id: string;
  rawName: string;
  quantity: string | null;
  unit: string | null;
  aisle: string | null;
  checked: boolean;
  inPantry?: boolean;
  sortOrder?: number;
};

type SortMode = "category" | "alpha" | "unchecked";

// Sentinel group key for items with no aisle (the "Other" bucket) — using a stable,
// locale-independent key (rather than the translated "Other" text) as the group
// identifier avoids that identifier changing across locales and lets it double as a
// dnd-kit droppable/group key.
const OTHER_KEY = "__other__";

export function ShoppingListView({
  listId,
  initialItems,
  readOnly = false,
}: {
  listId: string;
  initialItems: Item[];
  readOnly?: boolean;
}) {
  const t = useTranslations("mealPlan");
  const tShopping = useTranslations("shoppingLists");
  const tCommon = useTranslations("common");
  const [items, setItems] = useState<Item[]>(initialItems);
  const [movingToPantry, setMovingToPantry] = useState(false);
  const [query, setQuery] = useState("");
  const [sortMode, setSortMode] = useState<SortMode>("category");
  const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
  // Aisle the dragged item had when the drag started — captured so onDragEnd can tell
  // whether the item actually changed category (and needs a persisted aisle update) as
  // opposed to just being reordered within its original group.
  const dragStartAisleRef = useRef<string | null>(null);
  const [deleteTarget, setDeleteTarget] = useState<Item | null>(null);
  const [deleting, setDeleting] = useState(false);
  const [autoCategorizing, setAutoCategorizing] = useState(false);

  // Live updates: other collaborators' edits are picked up by polling rather than
  // pushed, so incoming server snapshots must not stomp on this tab's own in-flight
  // optimistic edits. dirtyUntilRef marks an item as "ours, don't overwrite" for a
  // few seconds after a local mutation; pendingDeleteIdsRef additionally hides items
  // this tab is deleting, since the server won't reflect that until the DELETE lands.
  const dirtyUntilRef = useRef<Map<string, number>>(new Map());
  const pendingDeleteIdsRef = useRef<Set<string>>(new Set());
  const isDraggingRef = useRef(false);
  const DIRTY_MS = 4000;

  function markDirty(...ids: string[]) {
    const until = Date.now() + DIRTY_MS;
    for (const id of ids) dirtyUntilRef.current.set(id, until);
  }

  useEffect(() => {
    const interval = setInterval(() => {
      if (isDraggingRef.current) return;
      fetch(`/api/v1/shopping-lists/${listId}/items`)
        .then((res) => (res.ok ? res.json() : null))
        .then((data: { items: Item[] } | null) => {
          if (!data) return;
          const now = Date.now();
          setItems((prev) => {
            const prevById = new Map(prev.map((i) => [i.id, i]));
            return data.items
              .filter((s) => !pendingDeleteIdsRef.current.has(s.id))
              .map((s) => {
                const dirty = (dirtyUntilRef.current.get(s.id) ?? 0) > now;
                return dirty ? (prevById.get(s.id) ?? s) : s;
              });
          });
        })
        .catch(() => {});
    }, 4000);
    return () => clearInterval(interval);
  }, [listId]);

  // Predefined categories, plus any custom category already in use on this list
  // (so a custom category someone created stays selectable for other items too),
  // plus "Other" (represented as `null` under the hood) always last.
  const customCategoriesInUse = useMemo(
    () => Array.from(new Set(items.map((i) => i.aisle).filter((a): a is string => !!a && !(GROCERY_CATEGORIES as readonly string[]).includes(a)))).sort(),
    [items]
  );
  const categoryOptions = useMemo(
    () => [...GROCERY_CATEGORIES, ...customCategoriesInUse],
    [customCategoriesInUse]
  );

  function categoryLabel(category: string): string {
    return (GROCERY_CATEGORIES as readonly string[]).includes(category)
      ? tShopping(`categories.${category}`)
      : category;
  }

  const checkedItems = items.filter((i) => i.checked);
  const isSearching = query.trim().length > 0;
  // Any item generated without an aisle (e.g. from an older list, before auto-categorization
  // existed, or added manually without one) — offering a one-click fix for those.
  const uncategorizedCount = items.filter((i) => !i.aisle).length;

  async function moveToPantry() {
    if (checkedItems.length === 0) return;
    setMovingToPantry(true);
    try {
      const res = await fetch("/api/v1/pantry/bulk", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          items: checkedItems.map((i) => ({
            rawName: i.rawName,
            quantity: i.quantity ?? undefined,
            unit: i.unit ?? undefined,
          })),
        }),
      });
      if (!res.ok) { toast.error(t("moveToPantryFailed")); return; }
      toast.success(t("addedToPantry", { count: checkedItems.length }));
    } finally {
      setMovingToPantry(false);
    }
  }

  async function toggleItem(item: Item) {
    if (readOnly) return;
    const next = !item.checked;
    markDirty(item.id);
    setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, checked: next } : i));
    try {
      const res = await fetch(`/api/v1/shopping-lists/${listId}/items/${item.id}`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ checked: next }),
      });
      if (!res.ok) throw new Error();
    } catch {
      setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, checked: !next } : i));
      toast.error(tCommon("updateFailed"));
    }
  }

  async function changeCategory(item: Item, nextAisle: string | null) {
    if (readOnly) return;
    const prevAisle = item.aisle;
    markDirty(item.id);
    setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, aisle: nextAisle } : i));
    try {
      const res = await fetch(`/api/v1/shopping-lists/${listId}/items/${item.id}`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ aisle: nextAisle }),
      });
      if (!res.ok) throw new Error();
    } catch {
      setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, aisle: prevAisle } : i));
      toast.error(tCommon("updateFailed"));
    }
  }

  async function deleteItem(item: Item) {
    if (readOnly) return;
    setDeleting(true);
    pendingDeleteIdsRef.current.add(item.id);
    const prevItems = items;
    setItems((prev) => prev.filter((i) => i.id !== item.id));
    try {
      const res = await fetch(`/api/v1/shopping-lists/${listId}/items/${item.id}`, { method: "DELETE" });
      if (!res.ok) throw new Error();
      setDeleteTarget(null);
    } catch {
      pendingDeleteIdsRef.current.delete(item.id);
      setItems(prevItems);
      toast.error(t("removeFailed"));
    } finally {
      setDeleting(false);
    }
  }

  // One-click fix for lists whose items predate auto-categorization (or were added
  // manually without a category) — guesses an aisle for every uncategorized item and
  // persists all of them in a single batched request rather than one PUT per item.
  async function autoCategorize() {
    const targets = items.filter((i) => !i.aisle);
    if (targets.length === 0) return;
    setAutoCategorizing(true);
    const updates = targets
      .map((i) => ({ id: i.id, aisle: guessAisle(i.rawName) }))
      .filter((u) => u.aisle !== null) as { id: string; aisle: string }[];

    if (updates.length === 0) {
      setAutoCategorizing(false);
      toast.error(t("autoCategorizeNoneFound"));
      return;
    }

    const byId = new Map(updates.map((u) => [u.id, u.aisle]));
    markDirty(...updates.map((u) => u.id));
    setItems((prev) => prev.map((i) => byId.has(i.id) ? { ...i, aisle: byId.get(i.id)! } : i));
    try {
      const res = await fetch(`/api/v1/shopping-lists/${listId}/items`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ items: updates }),
      });
      if (!res.ok) throw new Error();
      toast.success(t("autoCategorizeSuccess", { count: updates.length }));
    } catch {
      toast.error(tCommon("updateFailed"));
    } finally {
      setAutoCategorizing(false);
    }
  }

  // Persists a new within-group order for a set of reordered items in a single batched
  // request (rather than firing one PUT per dragged item).
  async function persistOrder(reordered: Item[]) {
    const updates = reordered.map((item, index) => ({ id: item.id, sortOrder: index }));
    markDirty(...updates.map((u) => u.id));
    try {
      const res = await fetch(`/api/v1/shopping-lists/${listId}/items`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ items: updates }),
      });
      if (!res.ok) throw new Error();
    } catch {
      toast.error(tCommon("updateFailed"));
    }
  }

  function groupKeyOf(item: Item): string {
    return item.aisle ?? OTHER_KEY;
  }

  // Persists just the aisle for one item, without touching local state — used at drag
  // end once the item has already visually settled into its destination group via
  // onDragOver. Reverts on failure (only the field, not its position — a rare-case
  // rollback, not worth re-deriving the exact prior array position for).
  async function persistAisleChange(itemId: string, nextAisle: string | null, prevAisle: string | null) {
    markDirty(itemId);
    try {
      const res = await fetch(`/api/v1/shopping-lists/${listId}/items/${itemId}`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ aisle: nextAisle }),
      });
      if (!res.ok) throw new Error();
    } catch {
      setItems((prev) => prev.map((i) => i.id === itemId ? { ...i, aisle: prevAisle } : i));
      toast.error(tCommon("updateFailed"));
    }
  }

  function handleDragStart(event: DragStartEvent) {
    isDraggingRef.current = true;
    const activeItem = items.find((i) => i.id === event.active.id);
    dragStartAisleRef.current = activeItem?.aisle ?? null;
  }

  // Live cross-category preview: without this, dragging over a different group's rows
  // does nothing visually until drop, since dnd-kit only computes a transform for items
  // that are actually present in the target group's array. Moving the item into the
  // destination group's position in `items` as soon as it's dragged over makes it
  // render there immediately, like any other multi-container dnd-kit board. Purely
  // local — nothing is persisted until drag end.
  function handleDragOver(event: DragOverEvent) {
    const { active, over } = event;
    if (!over || active.id === over.id) return;

    const activeItem = items.find((i) => i.id === active.id);
    const overItem = items.find((i) => i.id === over.id);
    if (!activeItem || !overItem) return;
    if (groupKeyOf(activeItem) === groupKeyOf(overItem)) return;

    setItems((prev) => {
      const withoutActive = prev.filter((i) => i.id !== activeItem.id);
      const overIndex = withoutActive.findIndex((i) => i.id === overItem.id);
      if (overIndex === -1) return prev;
      const next = [...withoutActive];
      next.splice(overIndex, 0, { ...activeItem, aisle: overItem.aisle });
      return next;
    });
  }

  // Single handler for the whole list (not per-group) — a per-group DndContext can
  // only ever resolve drops within its own group, which is exactly why cross-category
  // dragging didn't work before. By the time this fires, onDragOver has already moved
  // the item into its destination group locally, so this just finalizes the exact
  // position within that group and persists whatever actually changed: the new order,
  // and — only if the category actually changed since drag start — the new aisle.
  function handleDragEnd(event: DragEndEvent) {
    isDraggingRef.current = false;
    const { active, over } = event;
    const startAisle = dragStartAisleRef.current;
    dragStartAisleRef.current = null;

    if (!over) {
      // Dropped outside any droppable — onDragOver may have already moved the item
      // into a different group locally (pure preview, never persisted); put it back
      // rather than leaving a moved-but-unsaved item that only "resets" on refresh.
      setItems((prev) => prev.map((i) => i.id === active.id ? { ...i, aisle: startAisle } : i));
      return;
    }

    const activeItem = items.find((i) => i.id === active.id);
    if (!activeItem) return;

    const destKey = groupKeyOf(activeItem);
    const groupItems = items.filter((i) => groupKeyOf(i) === destKey);
    const oldIndex = groupItems.findIndex((i) => i.id === active.id);
    const newIndex = active.id === over.id
      ? oldIndex
      : groupItems.findIndex((i) => i.id === over.id);

    if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
      const reordered = arrayMove(groupItems, oldIndex, newIndex);
      const reorderedIds = new Set(reordered.map((i) => i.id));
      setItems((prev) => {
        let cursor = 0;
        return prev.map((i) => (reorderedIds.has(i.id) ? reordered[cursor++]! : i));
      });
      void persistOrder(reordered);
    }

    if (startAisle !== activeItem.aisle) {
      void persistAisleChange(activeItem.id, activeItem.aisle, startAisle);
    }
  }

  const filtered = useMemo(() => {
    const q = query.trim().toLowerCase();
    if (!q) return items;
    return items.filter((i) => i.rawName.toLowerCase().includes(q));
  }, [items, query]);

  const checkedCount = items.filter((i) => i.checked).length;

  if (items.length === 0) {
    return <p className="text-muted-foreground text-sm">{t("listEmptyState")}</p>;
  }

  // Search flattens category grouping — when hunting for a specific item you want to
  // find it regardless of which aisle it landed in, and headers-with-one-match reads
  // worse than a simple flat result list. Outside of search, "category" sort mode keeps
  // the grouped view (and is the only mode where drag-to-reorder applies, since
  // "within category order" is the only ordering concept reordering makes sense for).
  const showGroups = !isSearching && sortMode === "category";

  let flatItems: Item[] = [];
  if (!showGroups) {
    flatItems = [...filtered];
    if (sortMode === "alpha" || isSearching) {
      flatItems.sort((a, b) => a.rawName.localeCompare(b.rawName));
    } else if (sortMode === "unchecked") {
      flatItems.sort((a, b) => {
        if (a.checked !== b.checked) return a.checked ? 1 : -1;
        return (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || a.rawName.localeCompare(b.rawName);
      });
    }
  }

  const grouped = showGroups
    ? filtered.reduce<Record<string, Item[]>>((acc, item) => {
        const key = groupKeyOf(item);
        (acc[key] ??= []).push(item);
        return acc;
      }, {})
    : {};

  function groupLabel(key: string): string {
    return key === OTHER_KEY ? tShopping("aisleOther") : categoryLabel(key);
  }

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <p className="text-sm text-muted-foreground">{t("checkedCount", { checked: checkedCount, total: items.length })}</p>
        <div className="flex items-center gap-2">
          {!readOnly && uncategorizedCount > 0 && (
            <Button size="sm" variant="ghost" onClick={autoCategorize} disabled={autoCategorizing}>
              {autoCategorizing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
              {t("autoCategorize")}
            </Button>
          )}
          {checkedCount > 0 && (
            <Button size="sm" variant="outline" onClick={moveToPantry} disabled={movingToPantry}>
              {movingToPantry ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Package className="h-3.5 w-3.5" />}
              {t("moveToPantry", { count: checkedCount })}
            </Button>
          )}
        </div>
      </div>

      <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
        <div className="relative flex-1">
          <Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
          <Input
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder={t("searchItemsPlaceholder")}
            className="pl-8"
          />
        </div>
        <Select value={sortMode} onValueChange={(v) => setSortMode(v as SortMode)}>
          <SelectTrigger className="sm:w-48">
            <SelectValue>
              {(v: SortMode) => ({ category: t("sortByCategory"), alpha: t("sortByAlpha"), unchecked: t("sortByUnchecked") })[v]}
            </SelectValue>
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="category">{t("sortByCategory")}</SelectItem>
            <SelectItem value="alpha">{t("sortByAlpha")}</SelectItem>
            <SelectItem value="unchecked">{t("sortByUnchecked")}</SelectItem>
          </SelectContent>
        </Select>
      </div>

      {filtered.length === 0 ? (
        <p className="text-muted-foreground text-sm">{t("noSearchResults")}</p>
      ) : showGroups ? (
        <DndContext
          sensors={sensors}
          collisionDetection={closestCenter}
          onDragStart={handleDragStart}
          onDragOver={handleDragOver}
          onDragEnd={handleDragEnd}
        >
          <div className="space-y-6">
            {Object.entries(grouped).sort(([a], [b]) => groupLabel(a).localeCompare(groupLabel(b))).map(([key, groupItems]) => (
              <ItemGroup
                key={key}
                aisleLabel={groupLabel(key)}
                items={groupItems}
                showHeader={Object.keys(grouped).length > 1}
                readOnly={readOnly}
                categoryOptions={categoryOptions}
                categoryLabel={categoryLabel}
                tShopping={tShopping}
                onToggle={toggleItem}
                onChangeCategory={changeCategory}
                onDeleteRequest={setDeleteTarget}
              />
            ))}
          </div>
        </DndContext>
      ) : (
        <FlatItemList
          items={flatItems}
          readOnly={readOnly}
          categoryOptions={categoryOptions}
          categoryLabel={categoryLabel}
          tShopping={tShopping}
          onToggle={toggleItem}
          onChangeCategory={changeCategory}
          onDeleteRequest={setDeleteTarget}
        />
      )}

      <AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>{t("removeItemConfirmTitle")}</AlertDialogTitle>
            <AlertDialogDescription>
              {deleteTarget ? t("removeItemConfirmDescription", { name: deleteTarget.rawName }) : ""}
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
            <AlertDialogAction
              disabled={deleting}
              onClick={() => deleteTarget && deleteItem(deleteTarget)}
            >
              {deleting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : tCommon("delete")}
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
    </div>
  );
}

function ItemGroup({
  aisleLabel,
  items,
  showHeader,
  readOnly,
  categoryOptions,
  categoryLabel,
  tShopping,
  onToggle,
  onChangeCategory,
  onDeleteRequest,
}: {
  aisleLabel: string;
  items: Item[];
  showHeader: boolean;
  readOnly: boolean;
  categoryOptions: string[];
  categoryLabel: (category: string) => string;
  tShopping: ReturnType<typeof useTranslations>;
  onToggle: (item: Item) => void;
  onChangeCategory: (item: Item, category: string | null) => void;
  onDeleteRequest: (item: Item) => void;
}) {
  return (
    <div className="space-y-2">
      {showHeader && (
        <h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">{aisleLabel}</h2>
      )}
      <div className="rounded-xl border divide-y">
        <SortableContext items={items.map((i) => i.id)} strategy={verticalListSortingStrategy}>
          {items.map((item) => (
            <SortableItemRow
              key={item.id}
              item={item}
              readOnly={readOnly}
              draggable={!readOnly}
              categoryOptions={categoryOptions}
              categoryLabel={categoryLabel}
              tShopping={tShopping}
              onToggle={onToggle}
              onChangeCategory={onChangeCategory}
              onDeleteRequest={onDeleteRequest}
            />
          ))}
        </SortableContext>
      </div>
    </div>
  );
}

function FlatItemList({
  items,
  readOnly,
  categoryOptions,
  categoryLabel,
  tShopping,
  onToggle,
  onChangeCategory,
  onDeleteRequest,
}: {
  items: Item[];
  readOnly: boolean;
  categoryOptions: string[];
  categoryLabel: (category: string) => string;
  tShopping: ReturnType<typeof useTranslations>;
  onToggle: (item: Item) => void;
  onChangeCategory: (item: Item, category: string | null) => void;
  onDeleteRequest: (item: Item) => void;
}) {
  return (
    <div className="rounded-xl border divide-y">
      {items.map((item) => (
        <ItemRow
          key={item.id}
          item={item}
          readOnly={readOnly}
          categoryOptions={categoryOptions}
          categoryLabel={categoryLabel}
          tShopping={tShopping}
          onToggle={onToggle}
          onChangeCategory={onChangeCategory}
          onDeleteRequest={onDeleteRequest}
        />
      ))}
    </div>
  );
}

function SortableItemRow(props: ItemRowProps & { draggable: boolean }) {
  const { item, draggable } = props;
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id, disabled: !draggable });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
  };

  return (
    <div ref={setNodeRef} style={style} className={cn(isDragging && "z-10 relative bg-background")}>
      <ItemRow
        {...props}
        dragHandle={draggable ? (
          <button
            type="button"
            className="shrink-0 cursor-grab touch-none text-muted-foreground hover:text-foreground active:cursor-grabbing"
            {...attributes}
            {...listeners}
          >
            <GripVertical className="h-4 w-4" />
          </button>
        ) : null}
      />
    </div>
  );
}

type ItemRowProps = {
  item: Item;
  readOnly: boolean;
  categoryOptions: string[];
  categoryLabel: (category: string) => string;
  tShopping: ReturnType<typeof useTranslations>;
  onToggle: (item: Item) => void;
  onChangeCategory: (item: Item, category: string | null) => void;
  onDeleteRequest: (item: Item) => void;
  dragHandle?: React.ReactNode;
};

function ItemRow({ item, readOnly, categoryOptions, categoryLabel, tShopping, onToggle, onChangeCategory, onDeleteRequest, dragHandle }: ItemRowProps) {
  const [newCategory, setNewCategory] = useState("");
  return (
    <div className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/30 transition-colors">
      {dragHandle}
      <button
        onClick={() => onToggle(item)}
        disabled={readOnly}
        className="flex flex-1 items-center gap-3 text-left disabled:cursor-default"
      >
        <div className={cn(
          "h-5 w-5 rounded border-2 flex items-center justify-center shrink-0 transition-colors",
          item.checked ? "bg-primary border-primary" : "border-input"
        )}>
          {item.checked && <Check className="h-3 w-3 text-primary-foreground" />}
        </div>
        <span className={cn("flex-1 text-sm", item.checked && "line-through text-muted-foreground")}>
          {item.rawName}
        </span>
        {item.inPantry && (
          <span className="text-[10px] font-medium uppercase tracking-wide text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 rounded px-1.5 py-0.5 shrink-0">
            {tShopping("alreadyInPantry")}
          </span>
        )}
        {(hasQuantity(item.quantity) || item.unit) && (
          <span className={cn("text-xs text-muted-foreground tabular-nums shrink-0", item.checked && "opacity-50")}>
            {hasQuantity(item.quantity) ? formatQuantity(parseFloat(item.quantity!)) : ""}{item.unit ? ` ${item.unit}` : ""}
          </span>
        )}
      </button>
      {!readOnly && (
        <DropdownMenu>
          <DropdownMenuTrigger className="shrink-0 rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground">
            <MoreVertical className="h-4 w-4" />
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end">
            <DropdownMenuSub>
              <DropdownMenuSubTrigger>{tShopping("changeCategory")}</DropdownMenuSubTrigger>
              <DropdownMenuSubContent className="w-56">
                {categoryOptions.map((category) => (
                  <DropdownMenuItem key={category} onClick={() => onChangeCategory(item, category)}>
                    {categoryLabel(category)}
                  </DropdownMenuItem>
                ))}
                <DropdownMenuItem onClick={() => onChangeCategory(item, null)}>
                  {tShopping("aisleOther")}
                </DropdownMenuItem>
                <DropdownMenuSeparator />
                <div className="flex items-center gap-1 p-1.5" onClick={(e) => e.stopPropagation()}>
                  <Input
                    value={newCategory}
                    onChange={(e) => setNewCategory(e.target.value)}
                    onKeyDown={(e) => {
                      if (e.key === "Enter" && newCategory.trim()) {
                        onChangeCategory(item, newCategory.trim());
                        setNewCategory("");
                      }
                    }}
                    placeholder={tShopping("newCategoryPlaceholder")}
                    className="h-7 text-xs"
                  />
                  <Button
                    size="sm"
                    variant="ghost"
                    className="h-7 shrink-0 px-2 text-xs"
                    disabled={!newCategory.trim()}
                    onClick={() => { onChangeCategory(item, newCategory.trim()); setNewCategory(""); }}
                  >
                    {tShopping("addCategory")}
                  </Button>
                </div>
              </DropdownMenuSubContent>
            </DropdownMenuSub>
            <DropdownMenuSeparator />
            <DropdownMenuItem variant="destructive" onClick={() => onDeleteRequest(item)}>
              <Trash2 className="h-4 w-4" />
              {tShopping("deleteItem")}
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      )}
    </div>
  );
}
