import { memo, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigationActions } from "./NavigationProvider";
import {
  Bookmark,
  Check,
  CheckCheck,
  Clock,
  Eye,
  EyeOff,
  Play,
  Radio,
  RotateCcw,
  // Aliased: `Video` is this app's own domain type (lib/api), and it wins the name.
  Video as VideoIcon,
} from "lucide-react";
import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import DownloadButton from "./DownloadButton";
import clsx from "clsx";
import type { Video, VideoStatus } from "../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";

function Actions({
  video,
  onState,
  onResetState,
  onToggleSave,
  dense = false,
}: {
  video: Video;
  onState: (id: string, status: VideoStatus) => void;
  onResetState?: (id: string) => void;
  onToggleSave: (id: string, saved: boolean) => void;
  /** Tighter buttons for the small card, whose column bottoms out at 180px. */
  dense?: boolean;
}) {
  const { t } = useTranslation();
  const act = (status: VideoStatus) => (e: React.MouseEvent) => {
    e.preventDefault();
    e.stopPropagation();
    onState(video.id, video.status === status ? "new" : status);
  };
  // Pristine = never opened: default status and no resume position. The reset clears the
  // whole state (incl. an in-progress position the un-watch toggle can't touch).
  const resettable = video.status !== "new" || video.position_seconds > 0;
  const toggleSave = (e: React.MouseEvent) => {
    e.preventDefault();
    e.stopPropagation();
    onToggleSave(video.id, !video.saved);
  };
  // One button style for the row — AddToPlaylist and DownloadButton take it as a prop, so all six
  // stay the same size instead of two of them keeping their own default.
  // `dense` shrinks 28px buttons to 24 and halves the gap: six of them need 188px, but the small
  // card's text column bottoms out around 160 (its 180px column less the padding), so at its
  // narrowest the last button used to hang outside the card. 154 at dense.
  const btn = clsx("rounded-md hover:bg-surface text-muted hover:text-fg", dense ? "p-1" : "p-1.5");
  return (
    // flex-wrap is the safety net, not the plan: the sizing above is what makes them fit. If a
    // future button or a narrower column breaks that arithmetic, the row wraps to a second line
    // rather than silently rendering a half-clipped control outside the card.
    <div
      className={clsx(
        "flex flex-wrap opacity-0 group-hover:opacity-100 transition",
        dense ? "gap-0.5" : "gap-1",
      )}
    >
      <button
        onClick={act("watched")}
        title={video.status === "watched" ? t("card.watchedUnmark") : t("card.markWatched")}
        className={clsx(btn, video.status === "watched" && "text-accent")}
      >
        {video.status === "watched" ? (
          <CheckCheck className="w-4 h-4" />
        ) : (
          <Check className="w-4 h-4" />
        )}
      </button>
      <button
        onClick={toggleSave}
        title={video.saved ? t("card.savedRemove") : t("card.saveForLater")}
        className={clsx(btn, video.saved && "fill-current text-accent")}
      >
        <Bookmark className="w-4 h-4" />
      </button>
      <AddToPlaylist videoId={video.id} className={btn} />
      <DownloadButton videoId={video.id} title={video.title} className={btn} />
      <button
        onClick={act("hidden")}
        title={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
        className={clsx(btn, video.status === "hidden" && "text-accent")}
      >
        {video.status === "hidden" ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
      </button>
      {onResetState && resettable && (
        <button
          onClick={(e) => {
            e.preventDefault();
            e.stopPropagation();
            onResetState(video.id);
          }}
          title={t("card.resetState")}
          className={btn}
        >
          <RotateCcw className="w-4 h-4" />
        </button>
      )}
    </div>
  );
}

// Plain left-click opens the in-app player (the experiment); Ctrl/Cmd/middle-click
// keep the native "open youtube.com in a new tab" behavior via the underlying href.
function openInApp(
  e: React.MouseEvent,
  video: Video,
  onOpen?: (v: Video, startAt?: number | null) => void,
): void {
  if (!onOpen || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
  e.preventDefault();
  onOpen(video);
}

// A non-zero saved position on an unfinished video = "in progress": offer Continue / Restart
// instead of a plain Play. Note this is TRUE even with no known duration — in which case there is
// no percentage to draw, so the two are deliberately separate predicates.
function isInProgress(video: Video): boolean {
  return video.position_seconds > 0 && video.status !== "watched";
}
/** Resume position as 0-100, or 0 when there's nothing to draw. Shared by Thumb and the
 * thumbnail-less row, which has no image to draw the bar on but still owes you the state. */
function resumePct(video: Video): number {
  return isInProgress(video) && video.duration_seconds
    ? Math.min(99, Math.max(2, (video.position_seconds / video.duration_seconds) * 100))
    : 0;
}

// Thumb overlays duration / live state / saved ON the image. The one-line row has no image, so it
// renders the same FACTS as columns of its own — deliberately not shared with Thumb's badges:
// floating chips over artwork and a table cell are different presentations, and forcing one
// component to do both would be a variant-flag knot for no reuse.
// The cost is real though: the rules below (duration, live/upcoming, was_live, saved) are ALSO in
// Thumb, so a change to them must land in both. If they ever do change, share the decision — a
// videoBadges(video) -> Badge[] — and keep only the markup separate.
//
// A fixed GRID, not a flex row: each datum has to hold its own x across rows or the column stops
// reading as a column. The flex row this replaces let a "36:36" and a "3:31:09" push the marker
// beside them to different places, and pulled it left again whenever the duration was absent
// (live/upcoming) — visible as a ragged edge down the page.
function RowMetaCells({ video }: { video: Video }) {
  const { t } = useTranslation();
  // Icons, not the labelled chips the thumbnail uses: at a glance this is the least important thing
  // in the row, so it gets about a letter's worth of width. Icons rather than a coloured dot
  // because the three states must stay apart by SHAPE — in the youtube scheme --accent is red, so a
  // "live" dot and a "stream" dot would be the same dot.
  const status =
    video.live_status === "live"
      ? { Icon: Radio, cls: "text-red-500", label: t("card.live") }
      : video.live_status === "upcoming"
        ? { Icon: Clock, cls: "", label: t("card.upcoming") }
        : video.live_status === "was_live"
          ? { Icon: VideoIcon, cls: "text-accent", label: t("card.stream") }
          : null;
  const StatusIcon = status?.Icon;
  // 4rem + 1rem + 1rem + two 4px gaps = 104px, which the one-line row's width tally counts on —
  // widen a slot and the title column silently pays for it. rem, not px, so the whole cell tracks
  // the text-size setting the same way its contents do.
  return (
    <div className="shrink-0 grid grid-cols-[4rem_1rem_1rem] gap-1 items-center text-sm text-muted">
      {/* Right-aligned: a duration is a number, so the digits line up on their own edge (30px for
          "1:52" up to 59px for an 11-hour stream — 4rem holds the longest). */}
      <span className="text-right tabular-nums">
        {video.duration_seconds != null ? formatDuration(video.duration_seconds) : ""}
      </span>
      {/* role="img" + aria-label names each marker ONCE and reliably; `title` alone wouldn't (a
          bare span has no role, and screen readers don't dependably surface title on one), while a
          title plus an sr-only twin gets it announced twice. These are indicators, NOT the controls
          in Actions — the label must not tell anyone to click something that doesn't respond. */}
      <span>
        {StatusIcon && (
          <span role="img" aria-label={status.label} title={status.label}>
            <StatusIcon className={clsx("w-3.5 h-3.5", status.cls)} />
          </span>
        )}
      </span>
      <span>
        {video.saved && (
          <span role="img" aria-label={t("card.savedMark")} title={t("card.savedMark")}>
            <Bookmark className="w-3.5 h-3.5 text-accent fill-current" />
          </span>
        )}
      </span>
    </div>
  );
}

function Thumb({
  video,
  className,
  onOpen,
  small = false,
}: {
  video: Video;
  className?: string;
  onOpen?: (v: Video, startAt?: number | null) => void;
  /** A row/tile thumbnail (~160-176px): the hover controls go icon-only, since the labelled
   * Continue / Restart buttons are sized for the card's full-width image and get clipped here. */
  small?: boolean;
}) {
  const { t } = useTranslation();
  const inProgress = isInProgress(video);
  const pct = resumePct(video);

  // Open in the in-app player at an explicit position (null = resume from saved).
  const open = (startAt: number | null) => (e: React.MouseEvent) => {
    e.preventDefault();
    e.stopPropagation();
    onOpen?.(video, startAt);
  };

  return (
    <a
      href={video.watch_url}
      target="_blank"
      rel="noreferrer"
      onClick={(e) => openInApp(e, video, onOpen)}
      className={clsx(
        "block relative rounded-xl overflow-hidden bg-surface border border-border shadow-md group-hover:shadow-2xl transition-shadow",
        className,
      )}
    >
      {video.thumbnail_url ? (
        <img
          src={video.thumbnail_url}
          alt=""
          loading="lazy"
          decoding="async"
          width={1280}
          height={720}
          className="w-full h-full object-cover"
        />
      ) : (
        <div className="w-full h-full" />
      )}
      {video.duration_seconds != null ? (
        <span className="absolute bottom-1.5 right-1.5 bg-black/80 text-white text-[11px] font-medium px-1.5 py-0.5 rounded">
          {formatDuration(video.duration_seconds)}
        </span>
      ) : video.live_status === "live" || video.live_status === "upcoming" ? (
        <span
          className={`absolute bottom-1.5 right-1.5 text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
            video.live_status === "live" ? "bg-red-500/90 text-white" : "bg-black/80 text-white"
          }`}
        >
          {t(`card.${video.live_status}`)}
        </span>
      ) : null}
      {video.live_status === "was_live" && (
        <span className="absolute top-1.5 left-1.5 bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded">
          {t("card.stream")}
        </span>
      )}
      {video.saved && (
        <span className="absolute top-1.5 right-1.5 bg-accent text-accent-fg rounded-full p-1">
          <Bookmark className="w-3.5 h-3.5" />
        </span>
      )}
      {/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. */}
      {onOpen && (
        <div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 group-hover:opacity-100 transition-opacity">
          {inProgress ? (
            <>
              <button
                onClick={open(null)}
                title={t("card.continueTitle")}
                aria-label={t("card.continue")}
                className={clsx(
                  "inline-flex items-center justify-center gap-1.5 font-semibold bg-accent text-accent-fg shadow-lg hover:opacity-90 transition",
                  small ? "w-9 h-9 rounded-full" : "px-3 py-1.5 rounded-lg text-sm",
                )}
              >
                <Play className="w-4 h-4 fill-current" />
                {!small && t("card.continue")}
              </button>
              <button
                onClick={open(0)}
                title={t("card.restartTitle")}
                aria-label={t("card.restart")}
                className={clsx(
                  "inline-flex items-center justify-center gap-1.5 font-medium bg-white/15 text-white backdrop-blur-sm hover:bg-white/25 transition",
                  small ? "w-9 h-9 rounded-full" : "px-3 py-1.5 rounded-lg text-sm",
                )}
              >
                <RotateCcw className="w-4 h-4" />
                {!small && t("card.restart")}
              </button>
            </>
          ) : (
            <button
              onClick={open(null)}
              title={t("card.play")}
              aria-label={t("card.play")}
              className={clsx(
                "inline-flex items-center justify-center rounded-full bg-accent text-accent-fg shadow-lg hover:scale-105 transition",
                small ? "w-9 h-9" : "w-12 h-12",
              )}
            >
              <Play
                className={clsx("fill-current translate-x-[1px]", small ? "w-4 h-4" : "w-5 h-5")}
              />
            </button>
          )}
        </div>
      )}
      {/* Resume progress bar (sits at the very bottom, under the duration badge). */}
      {pct > 0 && (
        <div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
          <div className="h-full bg-accent" style={{ width: `${pct}%` }} />
        </div>
      )}
    </a>
  );
}

// The one-line row's column widths at the DEFAULT text size, mirroring the Tailwind classes the
// boxes actually use. They have to be mirrored by hand — Tailwind can't take a runtime value, and
// the classes have to stay, because they're rem: Settings' text-size slider drives the root font
// (`html { font-size: calc(16px * var(--font-scale)) }`, 0.9–1.3), so the boxes must grow with
// their contents. Inline pixels froze them and a channel name at 130% wanted 152px in a 128px box.
// Keep this table and the classes in step; each number is measured against the worst case in the
// LIBRARY and in BOTH languages — see the row's own comment for where each comes from.
const ROW_COL = {
  actions: 192, // w-48 — six buttons at 28 + five 4px gaps = 188
  channel: 128, // w-32
  meta: 104, //   RowMetaCells' own grid: 4rem + 1rem + 1rem + two 4px gaps
  views: 64, //   w-16
  published: 192, // w-48
} as const;
const ROW_GAP = 12; // gap-3
const ROW_PAD = 26; // px-3 both sides, plus a rounding allowance
const ROW_TITLE_MIN = 120; // not a column: the floor below which a title isn't worth showing

// What the row must measure before each column earns its place — cumulative, so each appears only
// once everything to its left AND a readable title already fit. Calibrated at the default text
// size; at 130% the columns grow ~30% while these don't, so they read slightly eager — the title
// gets tighter there, but it can't vanish, since the last column standing down frees ~200px.
const ROW_BASE = ROW_PAD + ROW_COL.actions + ROW_GAP + ROW_TITLE_MIN;
const ROW_FITS = {
  channel: ROW_BASE + ROW_GAP + ROW_COL.channel,
  meta: ROW_BASE + ROW_GAP + ROW_COL.channel + ROW_GAP + ROW_COL.meta,
  views: ROW_BASE + ROW_GAP + ROW_COL.channel + ROW_GAP + ROW_COL.meta + ROW_GAP + ROW_COL.views,
  published:
    ROW_BASE +
    ROW_GAP +
    ROW_COL.channel +
    ROW_GAP +
    ROW_COL.meta +
    ROW_GAP +
    ROW_COL.views +
    ROW_GAP +
    ROW_COL.published,
};

function VideoCard({
  video,
  view,
  width,
  onState,
  onResetState,
  onToggleSave,
  onOpen,
}: {
  video: Video;
  view: FeedView;
  /** Measured width of this card/row, from VirtualFeed. 0 until the first measure. */
  width: number;
  onState: (id: string, status: VideoStatus) => void;
  onResetState?: (id: string) => void;
  onToggleSave: (id: string, saved: boolean) => void;
  onOpen?: (v: Video, startAt?: number | null) => void;
}) {
  const { t, i18n } = useTranslation();
  const { openChannel } = useNavigationActions();
  const spec = FEED_VIEW_SPEC[view];
  const watched = video.status === "watched";
  // The thumbnail-less row drops Thumb, and with it the duration / live state / saved marker that
  // Thumb overlays on the image. Those are metadata, not decoration, so fold them into the meta
  // line here rather than let the mode silently lose them.
  const noThumb = !spec.thumb;
  // Each datum on its own, so the one-line row can give each a column of its own while the stacked
  // layouts still run them together as one sentence. The count comes in two shapes from one span —
  // one span so the exact-count tooltip covers all of whatever is rendered: prose needs the word
  // ("1.5K views · 2 min ago"), the row's column doesn't, because a table doesn't repeat its unit
  // on every line and the tooltip carries the figure regardless.
  const viewsCell = (withWord: boolean) =>
    video.view_count != null && (
      <span title={`${video.view_count.toLocaleString()} ${t("card.views")}`}>
        {formatViews(video.view_count)}
        {withWord && ` ${t("card.views")}`}
      </span>
    );
  const published = (
    <>
      {relativeTime(video.published_at)}
      {video.published_at && (
        <>
          {" · "}
          {formatDate(video.published_at, i18n.language)}
        </>
      )}
    </>
  );
  // Show the full text in a native tooltip only when it's actually cut off — vertically where the
  // title clamps to 2 lines, horizontally where a fixed column truncates it. Shared by the title
  // and the channel name: both truncate in some views and fit in others, and a tooltip that just
  // repeats what's already legible is noise.
  const titleRef = useRef<HTMLAnchorElement>(null);
  const channelRef = useRef<HTMLButtonElement>(null);
  const [titleClamped, setTitleClamped] = useState(false);
  const [channelClamped, setChannelClamped] = useState(false);
  useEffect(() => {
    const cut = (el: HTMLElement) =>
      el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1;
    const els: [HTMLElement | null, (v: boolean) => void][] = [
      [titleRef.current, setTitleClamped],
      [channelRef.current, setChannelClamped],
    ];
    const check = () => els.forEach(([el, set]) => el && set(cut(el)));
    check();
    const ro = new ResizeObserver(check);
    els.forEach(([el]) => el && ro.observe(el));
    return () => ro.disconnect();
    // `view` matters as much as the text: switching views returns a different branch, so React can
    // remount these nodes — without it the observer would be left watching detached ones.
  }, [video.title, video.channel_title, view]);
  const title = (
    <a
      ref={titleRef}
      href={video.watch_url}
      target="_blank"
      rel="noreferrer"
      onClick={(e) => openInApp(e, video, onOpen)}
      title={titleClamped ? (video.title ?? undefined) : undefined}
      className={clsx(
        "font-medium leading-snug hover:text-accent",
        noThumb ? "block truncate" : "line-clamp-2",
      )}
    >
      {video.title}
    </a>
  );
  const channelButton = (
    <button
      ref={channelRef}
      data-testid="video-card-channel"
      onClick={(e) => {
        e.stopPropagation();
        openChannel(video.channel_id, video.channel_title ?? t("card.thisChannel"));
      }}
      title={channelClamped ? (video.channel_title ?? undefined) : undefined}
      className="text-sm text-muted truncate block w-fit max-w-full text-left hover:text-fg"
    >
      {video.channel_title}
    </button>
  );
  const actions = (
    <Actions
      video={video}
      onState={onState}
      onResetState={onResetState}
      onToggleSave={onToggleSave}
      dense={spec.dense}
    />
  );
  // A permanent container, not a hover-only fill. Rows used to be fully transparent at rest and
  // paint an opaque bg-card on hover — so running the pointer down the list kept covering and
  // re-revealing the ambient backdrop behind them, which reads as the background flickering. The
  // cards never did this because glass-card always paints. No hover lift here: a 900px-wide row
  // jumping as the pointer crosses it is a lot more restless than a card doing it.
  const rowShell = "cv-row group glass-card glass-hover relative rounded-xl transition";

  if (spec.family === "row") {
    // A table without being one. Every column is a FIXED width, which is what makes the same datum
    // land on the same vertical line in every row. A grid can't align ACROSS rows here — VirtualFeed
    // renders each as its own subtree, so there's no shared parent to be the grid — but a grid is
    // still right WITHIN a cell (see RowMetaCells): fixed slots, so they line up across rows too.
    // Widths are measured, not chosen by eye — against the worst case in the LIBRARY and in BOTH
    // languages, since both have caught me out here:
    //   actions 188 (6 x 28 + 5 x 4)  ·  meta cells 104 (see RowMetaCells)  ·  views 48 (the bare
    //   "101.7K"; language-proof now the word is gone — with it, Hungarian's "megtekintés" needed
    //   139)  ·  published 183 ("11 months ago · Feb 28, 2026"; HU is shorter)  ·  channel 128,
    //   which is a deliberate cut rather than a fit: names run to 342px, the median is 87, and 12
    //   of 102 truncate here vs 5 at 160 — worth 32px to the title, since a clipped channel name
    //   still reads while a clipped view count loses the number.
    // Three ways this went wrong before: sizing off a PAGE of data (the badges came from 60 videos
    // whose longest was 2:00:58, then wrapped on the 11-hour one); measuring with a hand-built probe
    // instead of the real element (it read "101.7K megtekintés" as 122px where the live cell renders
    // 139); and checking that the COLUMNS aligned without checking the data inside them (the meta
    // cell was a flex row, so its contents slid about — see RowMetaCells).
    // The title takes what's left, which is constant per container width — so it aligns too.
    if (noThumb) {
      const pct = resumePct(video);
      const fits = (needs: number) => width === 0 || width >= needs;
      return (
        <div
          data-testid="video-card"
          data-video-id={video.id}
          className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}
        >
          {/* Actions lead: this is where the eye already is (the title is the thing you read), so
              they're the shortest pointer trip from it. */}
          <div className="shrink-0 w-48">{actions}</div>
          {/* Everything else is shrink-0, so the title is the only thing that can give — which means
              once the fixed columns outgrow the row, it gives ALL of it and vanishes. Columns drop
              from the right as the row narrows, least important first: a title with no date beats a
              date with no title. `width` is the ROW's own, so this holds whatever the rail and the
              filter panel are doing. Until the first measure lands (width 0) show everything: the
              row is off-screen for that frame anyway, and guessing narrow would flash. */}
          <div className="min-w-0 flex-1">{title}</div>
          {fits(ROW_FITS.channel) && <div className="shrink-0 w-32">{channelButton}</div>}
          {fits(ROW_FITS.meta) && <RowMetaCells video={video} />}
          {/* Right-aligned like the duration: it's a number, so the digits share an edge. 64px
              holds the widest bare form ("101.7K" at 48) in either language — dropping the word
              took this from 144 and handed the difference to the title. */}
          {fits(ROW_FITS.views) && (
            <div className="shrink-0 w-16 text-sm text-muted text-right tabular-nums truncate">
              {viewsCell(false)}
            </div>
          )}
          {fits(ROW_FITS.published) && (
            <div className="shrink-0 w-48 text-sm text-muted truncate">{published}</div>
          )}
          {/* Thumb draws this over the image; with no image it goes on the row itself. */}
          {pct > 0 && (
            <div className="absolute bottom-0 left-2 right-2 h-1 rounded-full bg-black/40">
              <div className="h-full rounded-full bg-accent" style={{ width: `${pct}%` }} />
            </div>
          )}
        </div>
      );
    }
  }

  // Everything below is a STACKED layout — the card and the thumbnailed row. Only they run the
  // data together as one sentence; the one-line row above gives each datum its own column and has
  // already returned, so building this for it would be waste dressed up as shared code.
  const views = viewsCell(true);
  const textBlock = (
    <>
      {title}
      <div className="mt-0.5">{channelButton}</div>
      <div className="text-xs text-muted mt-0.5">
        {views}
        {views && " · "}
        {published}
      </div>
    </>
  );

  if (spec.family === "row") {
    return (
      <div
        data-testid="video-card"
        data-video-id={video.id}
        className={clsx(rowShell, "flex gap-3 p-2", watched && "opacity-55")}
      >
        <Thumb
          video={video}
          className={clsx("aspect-video shrink-0", spec.actionsBelow ? "w-40" : "w-44")}
          onOpen={onOpen}
          small
        />
        <div className="min-w-0 flex-1 flex flex-col">
          {textBlock}
          {/* Tiles put the actions under the meta, in vertical space the thumbnail already
              reserves — which frees the ~168px the docked-right block used, so the block still
              works in a narrow column. mt-auto pins them to the tile's bottom edge (the flex row
              already stretches this column to full height), so they land in the same place
              whether the title above them ran to one line or three. */}
          {spec.actionsBelow && <div className="mt-auto pt-1">{actions}</div>}
        </div>
        {!spec.actionsBelow && actions}
      </div>
    );
  }

  // The flex column is what puts the actions on the card's bottom edge: the grid already stretches
  // every card in a row to the tallest one (align-items: stretch), so the column just needs to push
  // its last child down. Otherwise the action row floats under a 1-line title on one card and a
  // line lower on its 2-line neighbour — the same control landing somewhere different on each.
  // NOTE: do NOT add h-full here. A percentage height against the grid's content-derived height is
  // circular, and VirtualFeed measures each row with a ResizeObserver — the two together oscillate
  // by a pixel or two forever, which shows up as the whole page juddering.
  return (
    <div
      data-testid="video-card"
      data-video-id={video.id}
      className={clsx(
        "cv-card group glass-card glass-hover rounded-2xl transition-all duration-150 hover:-translate-y-1",
        "flex flex-col",
        spec.dense ? "p-2" : "p-2.5",
        watched && "opacity-55",
      )}
    >
      {/* The small card's image is in the SAME size class as a row's (its column bottoms out at
          180px), so it needs the icon-only controls just as much — the labelled pair is 194px wide
          and would spill straight out of it. */}
      <Thumb video={video} className="aspect-video" onOpen={onOpen} small={spec.dense} />
      <div className={clsx("flex gap-3 px-0.5 pb-0.5 flex-1", spec.dense ? "mt-2" : "mt-2.5")}>
        {/* The small card drops the channel avatar: at its ~180px column the 36px avatar plus the
            gap would leave the title barely half the width. The channel name is still below it. */}
        {!spec.dense && (
          <Avatar
            src={video.channel_thumbnail}
            fallback={video.channel_title ?? ""}
            className="w-9 h-9 rounded-full shrink-0 mt-0.5"
          />
        )}
        <div className="min-w-0 flex-1 flex flex-col">
          {textBlock}
          <div className="mt-auto pt-1">{actions}</div>
        </div>
      </div>
    </div>
  );
}

// Memoized so appending a feed page only renders the new cards, not every existing
// one (the callbacks from Feed are stable; non-overridden video objects keep their
// identity across renders).
export default memo(VideoCard);
