<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import {
  findDuplicateSnippets,
  listSnippets,
  mergeSnippets,
  type DuplicateGroup,
  type SnippetListItem,
} from "@/api/snippets";
import { useToastStore } from "@/stores/toast";

const router = useRouter();
const toast = useToastStore();

const snippets = ref<SnippetListItem[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const search = ref("");
let searchTimer: ReturnType<typeof setTimeout> | undefined;

// Reverse lookup — "what already lives here?" All three must match the same
// recorded location; path also matches anything beneath it.
const showLocationFilter = ref(false);
const locRepo = ref("");
const locPath = ref("");
const locSymbol = ref("");
const locationActive = computed(
  () => !!(locRepo.value.trim() || locPath.value.trim() || locSymbol.value.trim()),
);
function clearLocation() {
  locRepo.value = "";
  locPath.value = "";
  locSymbol.value = "";
  loadSnippets();
}
function toggleLocationFilter() {
  showLocationFilter.value = !showLocationFilter.value;
  // Collapsing while filtered would hide why the list is short.
  if (!showLocationFilter.value && locationActive.value) clearLocation();
}

// Drift check (#2086) — "attention" is everything whose recorded location or
// code no longer checks out, plus everything whose verdict expired because the
// snippet was edited since it was checked.
const needsAttentionOnly = ref(false);
function toggleAttention() {
  needsAttentionOnly.value = !needsAttentionOnly.value;
  loadSnippets();
}

// Multi-select → merge
const selectMode = ref(false);
const selectedIds = ref<Set<number>>(new Set());
const showMergeModal = ref(false);
const canonicalId = ref<number | null>(null);
const merging = ref(false);

// Near-duplicate report (#2088). Loaded on demand, not with the list: it's a
// pairwise scan and most visits to this page aren't a tidy-up.
const duplicateGroups = ref<DuplicateGroup[]>([]);
const dupLoading = ref(false);
const dupChecked = ref(false);
// Set while merging a SUGGESTED group. The report reaches the whole corpus, so
// its members need not all be on the current page — see selectedList.
const reviewingGroup = ref<DuplicateGroup | null>(null);

/** The records the merge modal acts on.
 *
 *  Normally that's the selection filtered against what's on screen. But a
 *  suggested group is corpus-wide: filtering it by the current page would render
 *  an incomplete set AND silently narrow what doMerge folds in, since it derives
 *  its source ids from this list. When a group is under review it is the
 *  authority. */
const selectedList = computed<{ id: number; title: string }[]>(() => {
  if (reviewingGroup.value) return reviewingGroup.value.snippets;
  return snippets.value.filter((s) => selectedIds.value.has(s.id));
});

async function loadDuplicates() {
  dupLoading.value = true;
  try {
    const data = await findDuplicateSnippets();
    duplicateGroups.value = data.groups;
    dupChecked.value = true;
  } catch {
    toast.show("Couldn't check for duplicates", "error");
  } finally {
    dupLoading.value = false;
  }
}

/** Hand a suggested group to the existing merge flow, pre-selected. The operator
 *  still picks which record survives and confirms — the report proposes, it
 *  never merges. */
function reviewGroup(group: DuplicateGroup) {
  reviewingGroup.value = group;
  selectedIds.value = new Set(group.note_ids);
  canonicalId.value = group.note_ids[0] ?? null;
  showMergeModal.value = true;
}

function exitSelectMode() {
  selectMode.value = false;
  selectedIds.value = new Set();
  reviewingGroup.value = null;
}
function toggleSelectMode() {
  if (selectMode.value) exitSelectMode();
  else selectMode.value = true;
}
function toggleSelect(id: number) {
  const next = new Set(selectedIds.value);
  if (next.has(id)) next.delete(id);
  else next.add(id);
  selectedIds.value = next;
}
function onCardActivate(s: SnippetListItem) {
  if (selectMode.value) toggleSelect(s.id);
  else router.push(`/snippets/${s.id}`);
}
function openMerge() {
  if (selectedIds.value.size < 2) return;
  canonicalId.value = selectedList.value[0]?.id ?? null;
  showMergeModal.value = true;
}
/** Dismiss the modal. Clears the reviewed group too — leaving it set would keep
 *  selectedList pinned to a corpus-wide set the operator has walked away from. */
function closeMerge() {
  showMergeModal.value = false;
  if (reviewingGroup.value) {
    reviewingGroup.value = null;
    selectedIds.value = new Set();
  }
}
async function doMerge() {
  const target = canonicalId.value;
  if (target == null) return;
  const sources = selectedList.value.map((s) => s.id).filter((id) => id !== target);
  if (!sources.length) return;
  merging.value = true;
  try {
    await mergeSnippets(target, sources);
    toast.show(`Merged ${sources.length} snippet${sources.length > 1 ? "s" : ""} in`);
    showMergeModal.value = false;
    const wasSuggested = reviewingGroup.value !== null;
    exitSelectMode();
    await loadSnippets();
    // The merged-away records are gone, so a stale report would keep offering
    // them. Re-run it rather than clearing, so the operator can work through
    // several groups without re-triggering the scan each time.
    if (wasSuggested && dupChecked.value) await loadDuplicates();
  } catch {
    toast.show("Failed to merge snippets", "error");
  } finally {
    merging.value = false;
  }
}

async function loadSnippets() {
  loading.value = true;
  error.value = null;
  try {
    const data = await listSnippets({
      q: search.value.trim() || undefined,
      repo: locRepo.value.trim() || undefined,
      path: locPath.value.trim() || undefined,
      symbol: locSymbol.value.trim() || undefined,
      verification: needsAttentionOnly.value ? "attention" : undefined,
    });
    snippets.value = data.snippets;
  } catch {
    error.value = "Couldn't load your snippets.";
    toast.show("Failed to load snippets", "error");
  } finally {
    loading.value = false;
  }
}

function onSearchInput() {
  clearTimeout(searchTimer);
  searchTimer = setTimeout(loadSnippets, 300);
}
// Same debounce for the location fields — typing a path shouldn't fire a query
// per keystroke.
const onLocationInput = onSearchInput;

onMounted(loadSnippets);

/** Titles are stored as "name — when to reach for it"; split for display. */
function splitTitle(title: string): { name: string; when: string } {
  const idx = title.indexOf(" — ");
  if (idx === -1) return { name: title, when: "" };
  return { name: title.slice(0, idx), when: title.slice(idx + 3) };
}

function languageOf(tags: string[]): string {
  return tags.find((t) => t && t !== "snippet") ?? "";
}

/** A snippet that has been offered repeatedly and never opened. The threshold
 *  is 3 rather than 1 because one or two surfacings is noise — the record may
 *  simply not have come up in a relevant context yet. */
function isDeadWeight(s: SnippetListItem): boolean {
  const u = s.usage;
  return !!u && u.pull_count === 0 && u.surfaced_count >= 3;
}

/** Short badge text, or "" to render nothing. A record nobody has surfaced yet
 *  gets no badge at all: "0 / 0" would read as a verdict when it's an absence
 *  of evidence. */
function usageBadge(s: SnippetListItem): string {
  const u = s.usage;
  if (!u || u.surfaced_count === 0) return "";
  return `${u.pull_count}/${u.surfaced_count} used`;
}

/** Short label for the drift verdict, or "" when there's nothing to say.
 *  An expired verdict is reported as "unchecked" whatever it used to say —
 *  it was about code that is no longer in the record. */
function driftBadge(s: SnippetListItem): string {
  const v = s.verification;
  if (!v) return "";
  if (!v.current) return "unchecked since edit";
  // Record<string, string>, not an object literal: `status` is a union that
  // includes "ok" and "unverified", and a literal would have to enumerate every
  // member just to say "nothing to show for these".
  const labels: Record<string, string> = {
    missing: "path gone",
    moved: "symbol moved",
    changed: "code drifted",
  };
  return labels[v.status] ?? "";
}

function driftTitle(s: SnippetListItem): string {
  const v = s.verification;
  if (!v) return "";
  const when = v.checked_at
    ? `Checked ${new Date(v.checked_at).toLocaleDateString()}`
    : "Checked";
  if (!v.current) {
    return (
      `${when}, but the snippet has been edited since — that verdict was about ` +
      `code this record no longer holds. Re-verify it.`
    );
  }
  const reasons: Record<string, string> = {
    missing: "the recorded path no longer exists",
    moved: "the file is there but the symbol isn't in it",
    changed: "the source no longer matches the recorded code",
  };
  const what = reasons[v.status] ?? "";
  return v.detail ? `${when}: ${what}. ${v.detail}` : `${when}: ${what}.`;
}

function usageTitle(s: SnippetListItem): string {
  const u = s.usage;
  if (!u) return "";
  const last = u.last_pulled_at
    ? `Last opened ${new Date(u.last_pulled_at).toLocaleDateString()}.`
    : "Never opened.";
  const verdict = isDeadWeight(s)
    ? " Offered repeatedly without ever being opened — consider rewriting its" +
      " “when to reach for it” so it says when, or deleting it. It takes a slot" +
      " in every future auto-inject menu."
    : "";
  return (
    `Surfaced to an agent ${u.surfaced_count}×, opened in full ` +
    `${u.pull_count}×. ${last}${verdict}`
  );
}
</script>

<template>
  <main class="page-container">
    <div class="page-header">
      <h1>Snippets</h1>
      <div class="header-actions">
        <button v-if="snippets.length" class="btn-ghost" @click="toggleSelectMode">
          {{ selectMode ? "Cancel" : "Select" }}
        </button>
        <button class="btn-primary" @click="router.push('/snippets/new')">
          + New snippet
        </button>
      </div>
    </div>
    <p class="page-sub">
      Reusable functions and components, recorded once so they surface before
      they're rewritten as a one-off.
    </p>

    <div class="search-row">
      <input
        v-model="search"
        type="search"
        class="search-input"
        placeholder="Search snippets…"
        aria-label="Search snippets"
        @input="onSearchInput"
        @keydown.enter="loadSnippets"
      />
      <button
        class="btn-ghost"
        :class="{ 'filter-on': locationActive }"
        :aria-expanded="showLocationFilter"
        aria-controls="location-filter"
        @click="toggleLocationFilter"
      >
        <!-- Say it in the label, not only in the accent — the state has to reach
             a screen reader too. -->
        {{ locationActive ? "Location · filtering" : "Location" }}
      </button>
      <button
        class="btn-ghost"
        :class="{ 'filter-on': needsAttentionOnly }"
        :aria-pressed="needsAttentionOnly"
        title="Show only snippets whose recorded location or code no longer checks out — including ones edited since they were last verified"
        @click="toggleAttention"
      >
        {{ needsAttentionOnly ? "Needs attention · filtering" : "Needs attention" }}
      </button>
      <button
        class="btn-ghost"
        :disabled="dupLoading"
        title="Look for snippets already recorded that resemble each other closely enough to be worth merging"
        @click="loadDuplicates"
      >
        {{ dupLoading ? "Checking…" : "Find duplicates" }}
      </button>
    </div>

    <!-- Near-duplicate report. Only ever a proposal — merging is a separate,
         confirmed act, and the operator chooses which record survives. -->
    <div v-if="dupChecked && !dupLoading" class="dup-panel">
      <p v-if="!duplicateGroups.length" class="dup-empty">
        No near-duplicates found. Nothing recorded resembles anything else closely
        enough to be worth merging.
      </p>
      <template v-else>
        <p class="dup-head">
          {{ duplicateGroups.length }} possible duplicate{{ duplicateGroups.length > 1 ? " sets" : " set" }}
          — review each before merging; a set is a suggestion, not a verdict.
        </p>
        <div v-for="(g, i) in duplicateGroups" :key="i" class="dup-group">
          <div class="dup-members">
            <span v-for="s in g.snippets" :key="s.id" class="dup-member">
              {{ splitTitle(s.title).name }}
            </span>
          </div>
          <span class="dup-score">{{ Math.round(g.top_score * 100) }}% alike</span>
          <button class="btn-ghost dup-action" @click="reviewGroup(g)">Review &amp; merge</button>
        </div>
      </template>
    </div>

    <!-- Reverse lookup: what's already kept in this repo / file / symbol. -->
    <div v-if="showLocationFilter" id="location-filter" class="location-row">
      <input
        v-model="locRepo"
        class="loc-input"
        placeholder="repo"
        aria-label="Filter by repo"
        @input="onLocationInput"
        @keydown.enter="loadSnippets"
      />
      <input
        v-model="locPath"
        class="loc-input loc-input-wide"
        placeholder="path — matches the file or anything under it"
        aria-label="Filter by path"
        @input="onLocationInput"
        @keydown.enter="loadSnippets"
      />
      <input
        v-model="locSymbol"
        class="loc-input"
        placeholder="symbol"
        aria-label="Filter by symbol"
        @input="onLocationInput"
        @keydown.enter="loadSnippets"
      />
      <button v-if="locationActive" class="loc-clear" @click="clearLocation">
        Clear
      </button>
    </div>

    <div v-if="loading" class="skeleton-grid">
      <div class="skeleton-card" v-for="i in 6" :key="i"></div>
    </div>

    <p v-else-if="error" class="error-msg">{{ error }}</p>

    <div v-else-if="snippets.length === 0" class="empty-state-rich">
      <div class="empty-icon">❭_</div>
      <p class="empty-title">
        {{ needsAttentionOnly
          ? "Everything checks out"
          : locationActive
            ? "Nothing kept at that location yet"
            : search.trim()
              ? "No snippets match your search"
              : "No snippets kept yet" }}
      </p>
      <p class="empty-sub">
        {{ needsAttentionOnly
          ? "No snippet has drifted from its recorded location or code — as far as anything has been checked. Snippets nobody has verified yet don't appear here."
          : locationActive
            ? "No recorded snippet lives there — so whatever you're about to write is new. Widen the path, or clear the filter."
            : search.trim()
              ? "Try a different term, or clear the search."
              : "Record a reusable function or component and it will be offered back to you later." }}
      </p>
      <button v-if="needsAttentionOnly" class="empty-action" @click="toggleAttention">
        Show all snippets
      </button>
      <button v-else-if="locationActive" class="empty-action" @click="clearLocation">
        Clear location filter
      </button>
      <button
        v-else-if="!search.trim()"
        class="empty-action"
        @click="router.push('/snippets/new')"
      >
        New snippet →
      </button>
    </div>

    <div v-else class="snippets-grid">
      <div
        v-for="s in snippets"
        :key="s.id"
        class="snippet-card"
        :class="{ selected: selectedIds.has(s.id) }"
        role="button"
        tabindex="0"
        :aria-pressed="selectMode ? selectedIds.has(s.id) : undefined"
        @click="onCardActivate(s)"
        @keydown.enter="onCardActivate(s)"
      >
        <div class="card-header">
          <span
            v-if="selectMode"
            class="select-box"
            :class="{ on: selectedIds.has(s.id) }"
            aria-hidden="true"
          ></span>
          <span class="snippet-name">{{ splitTitle(s.title).name }}</span>
          <span v-if="languageOf(s.tags)" class="lang-pill">{{ languageOf(s.tags) }}</span>
        </div>
        <p v-if="splitTitle(s.title).when" class="snippet-when">
          {{ splitTitle(s.title).when }}
        </p>
        <div class="card-footer">
          <span class="meta-date">Updated {{ new Date(s.updated_at).toLocaleDateString() }}</span>
          <span v-if="driftBadge(s)" class="drift-tag" :title="driftTitle(s)">
            {{ driftBadge(s) }}
          </span>
          <span
            v-if="usageBadge(s)"
            class="usage-tag"
            :class="{ 'usage-dead': isDeadWeight(s) }"
            :title="usageTitle(s)"
          >
            {{ usageBadge(s) }}
          </span>
          <span v-if="s.shared" class="shared-tag" :title="`Shared by ${s.owner ?? 'another user'} — a suggestion, not your own record`">
            by {{ s.owner ?? "another user" }}
          </span>
        </div>
      </div>
    </div>

    <!-- Select action bar -->
    <div v-if="selectMode" class="select-bar">
      <span class="select-count">{{ selectedIds.size }} selected</span>
      <span class="select-hint">Pick two or more to unify into one canonical snippet.</span>
      <button class="btn-primary" :disabled="selectedIds.size < 2" @click="openMerge">
        Merge…
      </button>
    </div>

    <!-- Merge modal -->
    <teleport to="body">
      <div v-if="showMergeModal" class="modal-overlay" @click.self="closeMerge">
        <div class="modal-card" role="dialog" aria-modal="true" aria-label="Merge snippets">
          <h3 class="modal-title">Merge snippets</h3>
          <p class="modal-desc">
            Keep one as the canonical record — the others are folded into it (their
            locations are added) and moved to the trash, where they can be restored.
          </p>
          <div class="merge-choices">
            <label
              v-for="s in selectedList"
              :key="s.id"
              class="merge-choice"
              :class="{ chosen: canonicalId === s.id }"
            >
              <input type="radio" name="canonical" :value="s.id" v-model="canonicalId" />
              <span class="merge-choice-name">{{ splitTitle(s.title).name }}</span>
              <span class="merge-choice-tag">{{ canonicalId === s.id ? "keep" : "fold in" }}</span>
            </label>
          </div>
          <div class="modal-actions">
            <button class="modal-btn" @click="closeMerge">Cancel</button>
            <button
              class="modal-btn modal-btn-primary"
              :disabled="merging || canonicalId == null"
              @click="doMerge"
            >
              {{ merging ? "Merging…" : "Merge" }}
            </button>
          </div>
        </div>
      </div>
    </teleport>
  </main>
</template>

<style src="@/assets/dup-report.css" />
<style scoped>
.page-header {
  margin-bottom: 0.35rem;   /* tighter than the shared recipe: .page-sub follows */
}
.page-sub {
  margin: 0 0 1.25rem;
  color: var(--fs-text-secondary);
  font-size: 0.9rem;
  line-height: 1.5;
  max-width: 60ch;
}

/* Moss action-primary per Hybrid — utility action, not a brand moment. */

.search-row {
  margin-bottom: 1.25rem;
  display: flex;
  gap: 0.5rem;
  align-items: center;
  flex-wrap: wrap;
}
/* Filter is engaged — the accent marks "you are here", per the design system. */
.filter-on {
  border-color: var(--fs-accent);
  color: var(--fs-accent);
}

.location-row {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  flex-wrap: wrap;
  margin: -0.5rem 0 1.25rem;
}
.loc-input {
  flex: 1 1 8rem;
  min-width: 0;
  max-width: 12rem;
  padding: 0.4rem 0.65rem;
  border: 1px solid var(--fs-border-color);
  border-radius: var(--fs-radius-sm);
  background: var(--fs-surface-page);
  color: var(--fs-text-primary);
  font-size: 0.85rem;
  font-family: var(--fs-font-mono);
  box-sizing: border-box;
}
.loc-input-wide {
  flex: 2 1 16rem;
  max-width: 26rem;
}
.loc-input::placeholder {
  font-family: inherit;
  color: var(--fs-text-tertiary);
}
.loc-input:focus {
  outline: none;
  border-color: var(--fs-accent);
  box-shadow: var(--fs-focus-ring);
}
.loc-clear {
  padding: 0.4rem 0.65rem;
  border: none;
  background: transparent;
  color: var(--fs-text-secondary);
  font-size: 0.85rem;
  font-family: inherit;
  cursor: pointer;
  text-decoration: underline;
}
.loc-clear:hover {
  color: var(--fs-text-primary);
}
.search-input {
  width: 100%;
  max-width: 420px;
  padding: 0.5rem 0.8rem;
  border: 1px solid var(--fs-border-color);
  border-radius: var(--fs-radius-sm);
  background: var(--fs-surface-page);
  color: var(--fs-text-primary);
  font-size: 0.9rem;
  font-family: inherit;
  box-sizing: border-box;
}
.search-input:focus {
  outline: none;
  border-color: var(--fs-accent);
  box-shadow: var(--fs-focus-ring);
}

.error-msg {
  margin-top: 1rem;
}

.empty-state-rich {
  text-align: center;
  padding: 3rem 1rem;
  color: var(--fs-text-tertiary);
}
.empty-icon {
  font-family: var(--fs-font-mono);
  font-size: 2rem;
  margin-bottom: 0.75rem;
  opacity: 0.35;
}
.empty-sub {
  max-width: 44ch;
  margin-inline: auto;
  line-height: 1.5;
}
.empty-action {
  display: inline-block;
  padding: 0.4rem 1rem;
  border: 1px solid var(--fs-action-primary);
  border-radius: var(--fs-radius-sm);
  color: var(--fs-action-primary);
  background: none;
  cursor: pointer;
  font-size: 0.85rem;
  transition: background 0.15s, color 0.15s;
}
.empty-action:hover {
  background: var(--fs-action-primary);
  color: var(--fs-text-on-action);
}

.skeleton-grid,
.snippets-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  gap: 1rem;
}
.skeleton-card {
  height: 96px;
  border-radius: var(--fs-radius-lg);
  background: linear-gradient(90deg, var(--fs-surface-raised) 25%, var(--fs-border-color) 50%, var(--fs-surface-raised) 75%);
  background-size: 200% 100%;
  animation: skeleton-shimmer 1.4s ease infinite;
}
@keyframes skeleton-shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

.snippet-card {
  background: var(--fs-surface-raised);
  border: 1px solid var(--fs-border-color);
  border-radius: var(--fs-radius-lg);
  padding: 0.9rem 1rem;
  cursor: pointer;
  transition: border-color 0.15s, box-shadow 0.15s, transform 0.18s ease;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}
.snippet-card:hover {
  border-color: var(--fs-accent);
  box-shadow: 0 2px 8px var(--color-shadow);
  transform: translateY(-2px);
}
.snippet-card:focus-visible {
  outline: none;
  border-color: var(--fs-accent);
  box-shadow: var(--fs-focus-ring);
}

.card-header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 0.5rem;
}
.snippet-name {
  font-size: 0.95rem;
  font-weight: 500;
  color: var(--fs-text-primary);
  min-width: 0;
  flex: 1;
  word-break: break-word;
  font-family: var(--fs-font-mono);
}

/* Language tag — accent pill per the design system's tag treatment. */
.lang-pill {
  font-size: 0.68rem;
  font-weight: 500;
  padding: 0.12rem 0.45rem;
  border-radius: 999px;
  flex-shrink: 0;
  white-space: nowrap;
  background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
  color: var(--fs-accent);
}

.snippet-when {
  font-size: 0.83rem;
  color: var(--fs-text-secondary);
  margin: 0;
  line-height: 1.45;
}

.card-footer {
  margin-top: auto;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
}
.meta-date {
  font-size: 0.73rem;
  color: var(--fs-text-tertiary);
}
/* Marks a record someone else owns. Present only on shared rows, so an
   unmarked card is unambiguously the operator's own. */
.shared-tag {
  font-size: 0.7rem;
  padding: 0.1rem 0.4rem;
  border-radius: 4px;
  white-space: nowrap;
  background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
  color: var(--fs-text-tertiary);
}

.dup-action {
  white-space: nowrap;
}

/* Drift is a stronger signal than dead weight: the record may be actively
   misleading, not merely unused. Danger tone, and it sits first in the footer. */
.drift-tag {
  font-size: 0.7rem;
  padding: 0.1rem 0.4rem;
  border-radius: 4px;
  white-space: nowrap;
  background: color-mix(in srgb, var(--fs-error) 15%, transparent);
  color: var(--fs-error);
}

.usage-tag {
  font-size: 0.7rem;
  padding: 0.1rem 0.4rem;
  border-radius: 4px;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
  background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
  color: var(--fs-text-tertiary);
}

/* Dead weight is a nudge, not an error — it warns in the warning colour rather
   than the danger one, because the record isn't broken, just unearned. */
.usage-tag.usage-dead {
  background: color-mix(in srgb, var(--fs-warning) 18%, transparent);
  color: var(--fs-warning);
}

/* Header + select-mode */
.header-actions {
  display: flex;
  gap: 0.5rem;
  align-items: center;
}

/* Selected card = 2px accent border per the design system (featured/active). */
.snippet-card.selected {
  border-color: var(--fs-accent);
  border-width: 2px;
  padding: calc(0.9rem - 1px) calc(1rem - 1px);
}
.select-box {
  width: 16px;
  height: 16px;
  flex-shrink: 0;
  margin-top: 0.15rem;
  border: 1px solid var(--fs-border-color);
  border-radius: 4px;
  background: transparent;
  transition: background 0.12s, border-color 0.12s;
}
.select-box.on {
  background: var(--fs-accent);
  border-color: var(--fs-accent);
}

.select-bar {
  position: sticky;
  bottom: 1rem;
  margin-top: 1.5rem;
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.6rem 0.9rem;
  background: var(--fs-surface-raised);
  border: 1px solid var(--fs-border-color);
  border-radius: var(--fs-radius-lg);
  box-shadow: 0 4px 16px var(--color-shadow);
}
.select-count {
  font-weight: 500;
  font-size: 0.9rem;
  color: var(--fs-text-primary);
}
.select-hint {
  font-size: 0.8rem;
  color: var(--fs-text-tertiary);
  flex: 1;
  min-width: 0;
}

/* Merge modal */
.modal-card {
  max-width: 460px;
  display: flex;
  flex-direction: column;
  gap: 1rem;
}
.modal-title {
  margin: 0;
  font-size: 1.1rem;
}
.modal-desc {
  margin: 0;
  font-size: 0.85rem;
  color: var(--fs-text-secondary);
  line-height: 1.5;
}
.merge-choices {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}
.merge-choice {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0.5rem 0.7rem;
  border: 1px solid var(--fs-border-color);
  border-radius: var(--fs-radius-sm);
  cursor: pointer;
}
.merge-choice.chosen {
  border-color: var(--fs-accent);
  background: color-mix(in srgb, var(--fs-accent) 8%, transparent);
}
.merge-choice-name {
  flex: 1;
  min-width: 0;
  font-family: var(--fs-font-mono);
  font-size: 0.85rem;
  word-break: break-word;
}
.merge-choice-tag {
  font-size: 0.68rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--fs-text-tertiary);
  flex-shrink: 0;
}

@media (max-width: 600px) {
  .snippets-grid {
    grid-template-columns: 1fr;
  }
  .select-hint {
    display: none;
  }
}
</style>
