<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useNotesStore } from "../stores/notes";
import { NOTE_CARD_SURFACE, labelChipClasses } from "../notes/colors";
import type { Note } from "../stores/notes";
import Icon from "./Icon.vue";
import LinkPreview from "./LinkPreview.vue";
import MarkdownText from "./MarkdownText.vue";
import {
  cardIdAt,
  draggingId,
  DRAG_THRESHOLD_PX,
  endCardDrag,
  overId,
} from "../composables/useCardDrag";
import { formatReminder, formatTrashCountdown, isOverdue, trashDaysLeft } from "../notes/datetime";
import { useConfigStore } from "../stores/config";

const props = defineProps<{ note: Note; reorderable?: boolean; active?: boolean }>();
const emit = defineEmits<{
  (e: "open", note: Note): void;
  (e: "dragstart", note: Note): void;
  (e: "dragend", note: Note): void;
  /** The card this one was dropped ON, by id — the dragged card hit-tests the DOM
   *  for it, so an id is all it can know without looking the note up again. */
  (e: "drop", targetId: string): void;
  /** A reminder was completed or snoozed. The store reconciles itself, so the board
   *  needs nothing — this is for hosts holding their OWN list (the reminders lens
   *  fetches separately) that must re-query to drop the note they just cleared. */
  (e: "reminder-changed"): void;
}>();
const notes = useNotesStore();
const config = useConfigStore();

// --- Retention countdown. A note in Trash is on a clock, and the card is the only
// place someone browsing Trash would ever find that out in time to restore it.
// Null whenever nothing is going to happen: not trashed, or retention turned off. ---
const trashDays = computed(() =>
  props.note.trashed ? trashDaysLeft(props.note.deleted_at, config.trashRetentionDays) : null,
);
const trashCountdown = computed(() => formatTrashCountdown(trashDays.value));
// Same red the overdue reminder uses — the last few days are worth noticing.
const trashUrgent = computed(() => trashDays.value !== null && trashDays.value <= 3);

// The card previews the first image inline; non-image files show as compact chips.
const firstImage = computed(() => props.note.attachments.find((a) => a.mime.startsWith("image/")));
const otherAttachments = computed(() => props.note.attachments.filter((a) => !a.mime.startsWith("image/")));

// How much of a note the CARD shows. Android has always clamped to 8
// (`MAX_PREVIEW_LINES`); the web rendered the whole body, so one long note could
// produce a card taller than the screen and push everything else off the board.
//
// It matters more now that the title is gone (M13 step 4). The first line used to be
// the thing your eye caught; with one weight throughout, an unbounded card is just a
// wall, and the note next to it is the one you were looking for.
//
// Clamped in the STRING rather than with CSS `line-clamp`, which needs a
// `-webkit-box` and behaves unreliably around the block elements MarkdownText emits
// (lists, quotes, fenced code). This is deterministic, matches Android's semantics
// exactly, and skips parsing a body the card was never going to show.
const PREVIEW_LINES = 8;

// --- Links ------------------------------------------------------------------
//
// A note that is NOTHING but a URL is a link, and its preview is the whole card —
// showing the raw URL underneath a card that already says where it goes is saying the
// same thing twice, badly. A URL mentioned *inside* a note is a footnote to it, and
// gets a compact strip at the bottom instead.
//
// Whitespace either side still counts as lone: someone pasting a link rarely trims it.
const LONE_URL_RE = /^\s*(https?:\/\/[^\s<>"'\])]+)\s*$/;

const isLoneUrl = computed(() => LONE_URL_RE.test(props.note.body) && !props.note.items.length);

/** The preview for a lone-URL note — null while it is still being fetched, or if it
 *  could never be fetched at all. */
const loneUrlPreview = computed(() => {
  if (!isLoneUrl.value) return null;
  const url = props.note.body.trim();
  return props.note.previews.find((p) => p.url === url) ?? null;
});

const bodyPreview = computed(() => {
  const lines = props.note.body.split("\n");
  if (lines.length <= PREVIEW_LINES) return props.note.body;
  return lines.slice(0, PREVIEW_LINES).join("\n") + "\n…";
});

/** Tick a box without opening the note — the common gesture on a board.
 *
 *  The index is the item's ordinal in the WHOLE body, which survives the preview
 *  clamp above because that only ever drops lines from the end. `updateItem` takes
 *  it as the item id, which is exactly what an id is now (M304). */
function toggleTask(index: number, checked: boolean) {
  void notes.updateItem(props.note.id, String(index), { checked });
}

const root = ref<HTMLElement | null>(null);

// --- Drag-to-reorder. Pointer Events, gated behind an explicit grip handle so a
// plain tap/click/select never starts a drag by accident. `dragging` dims the
// source card; `dragOver` shows where the drop will land.
//
// Pointer rather than native HTML5 drag-and-drop because that API never fires from
// touch — on a phone this did nothing at all. One code path now covers mouse, touch
// and stylus. See composables/useCardDrag.ts for why the state is shared. ---
const canDrag = () => !!props.reorderable && !props.note.trashed;
const dragging = computed(() => draggingId.value === props.note.id);
const dragOver = computed(() => overId.value === props.note.id);

/** The pointer that owns the current gesture; null when no press is in flight. */
let activePointer: number | null = null;
let startX = 0;
let startY = 0;
/** A press becomes a drag only after the threshold — below it, it's still a tap. */
let moved = false;

function onGripDown(e: PointerEvent) {
  if (!canDrag()) return;
  activePointer = e.pointerId;
  startX = e.clientX;
  startY = e.clientY;
  moved = false;
  // Capture so the gesture keeps reporting to this grip even once the finger has
  // travelled onto another card — without it the stream stops at the first
  // boundary crossing.
  (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
  // Claims the gesture from the browser's own scroll/text-selection handling.
  e.preventDefault();
}

function onGripMove(e: PointerEvent) {
  if (activePointer !== e.pointerId) return;
  if (!moved) {
    if (Math.hypot(e.clientX - startX, e.clientY - startY) < DRAG_THRESHOLD_PX) return;
    moved = true;
    draggingId.value = props.note.id;
    emit("dragstart", props.note);
  }
  overId.value = cardIdAt(e.clientX, e.clientY, props.note.id);
}

function onGripUp(e: PointerEvent) {
  if (activePointer !== e.pointerId) return;
  const target = moved ? overId.value : null;
  const wasDragging = moved;
  activePointer = null;
  moved = false;
  endCardDrag();
  if (target) emit("drop", target);
  // Always paired with dragstart, dropped or not, so the parent can clear its own
  // tracked source rather than leaking it into the next gesture.
  if (wasDragging) emit("dragend", props.note);
}

// Fires when the system takes the gesture away (an incoming call, a browser-level
// scroll taking over). Treated as a cancel: no reorder, no half-set state left.
function onGripCancel(e: PointerEvent) {
  if (activePointer !== e.pointerId) return;
  const wasDragging = moved;
  activePointer = null;
  moved = false;
  endCardDrag();
  if (wasDragging) emit("dragend", props.note);
}

// When this card becomes the keyboard-focused card, scroll it into view.
watch(
  () => props.active,
  (a) => {
    if (a) root.value?.scrollIntoView({ block: "nearest", behavior: "smooth" });
  },
);

async function completeReminder(): Promise<void> {
  await notes.completeReminder(props.note.id);
  emit("reminder-changed");
}

async function snoozeReminder(minutes: number): Promise<void> {
  await notes.snoozeReminder(props.note.id, minutes);
  emit("reminder-changed");
}

// Only the tags the BODY is not already showing. `via_tag` means exactly "backed by
// text still in the note" since M311, so a chip for one printed the same tag twice —
// once where it was typed, once in this row — and the loud copy was the duplicate. A
// tag left in prose is tinted where it sits instead (MarkdownInline). What survives
// here is what the body cannot say: a tag lifted off its own line, and a label added
// through the picker.
const chipLabels = computed(() => props.note.labels.filter((lb) => !lb.via_tag));

// The colour the operator stored for each of this note's tags, keyed by lowercased
// name — what MarkdownInline needs to tint a `#tag` the same as its chip would be.
// Lowercased because tags dedupe case-insensitively, so `#Todo` and `#todo` are one.
const tagColors = computed<Record<string, string>>(() => {
  const map: Record<string, string> = {};
  for (const lb of props.note.labels) map[lb.name.toLowerCase()] = lb.color;
  return map;
});

</script>

<template>
  <div
    ref="root"
    class="group relative mb-4 break-inside-avoid rounded-xl border border-[#b8b8b8] p-3 shadow-sm transition hover:shadow-md dark:border-[#404040]"
    :class="[
      NOTE_CARD_SURFACE,
      dragging ? 'opacity-40' : '',
      dragOver
        ? 'scale-[1.02] shadow-lg ring-2 ring-brand ring-offset-2 ring-offset-white dark:ring-offset-neutral-950'
        : '',
      active ? 'ring-2 ring-brand' : '',
    ]"
    :data-note-id="note.id"
  >
    <!-- THE EDGE IS THE CARD'S BOUNDARY, and since M315 it is the ONLY thing that
         varies from the board: the fill is one neutral (NOTE_CARD_SURFACE) and no
         longer says anything about the note. That makes this line load-bearing rather
         than decorative — it is what a card IS.

         It was already neutral before the fill was. The version that came from the
         palette was a `{hue}-900` border and failed twice over: the line was the
         loudest element on the card (1.56-2.09 against its own fill, where the fill
         managed 1.03-1.05 against the board) AND carried the same information the fill
         did, so a board of them read as a grid of outlines. A neutral line carries no
         information at all, which is exactly what lets it be structure. The fill is
         the same argument one size up, made two milestones later.

         MEASURED AGAINST ONE FILL NOW, and deliberately left where it was. #b8b8b8 on
         white is 1.98 and #404040 on #171717 is 1.73 — both inside the ranges these
         values already shipped at across twenty fills (light 1.57-1.98, dark
         1.58-1.73), but at the top of them rather than the ~1.6-1.7 the pair was
         originally matched on. Softening the light edge to re-match would weaken the
         only boundary a white card on a #fafafa board has, and the complaint that
         started M315 was about fill, never about edge weight. If an operator pass
         disagrees it is one constant, in two files.

         NOT a translucent black/white edge, which is the tidier-looking way to do this
         and was measured and rejected: a border composites over what is under it, so
         `border-white/20` came out #56396d on a purple card and #a3c9c1 on a teal one.
         With one fill that argument no longer bites — but an opaque grey is what the
         Android side must also write, and two surfaces stating the same hex is how
         they stay the same card.

         `shadow-sm`, back down from `shadow`: the border is the boundary again, so the
         shadow is only depth. -->
    <!-- TAGS FIRST. They used to sit under everything else, which on a tall note put
         the one thing that says what a note IS below the fold of a glance. A board is
         scanned, not read, and the answer to "which of these is about the thing I am
         looking for" should be the first thing the eye lands on rather than the last.

         Above the image and the body rather than beside them, because the body's first
         line is the note's NAME (M13 steps 3 and 4) and a chip floated next to it would
         compete with the thing that identifies the note. -->
    <div v-if="chipLabels.length" class="mb-2 flex flex-wrap gap-1">
      <!-- Every chip carries the `#`, not just the ones derived from body text. That
           branch used to distinguish a `#tag` from a picker label; it cannot any more,
           because a tag whose text is still in the body no longer reaches this row at
           all. What is left is all the same thing to the eye and to the vocabulary —
           and the hash is what keeps a lifted chip reading as the `#todo` somebody
           typed. Android's row says the same, which it did not before. -->
      <span
        v-for="lb in chipLabels"
        :key="lb.id"
        class="rounded-full px-2 py-0.5 text-xs"
        :class="labelChipClasses(lb)"
        >#{{ lb.name }}</span
      >
    </div>

    <img
      v-if="firstImage"
      :src="firstImage.url"
      alt=""
      loading="lazy"
      decoding="async"
      class="mb-2 max-h-48 w-full cursor-pointer rounded-lg object-cover"
      @click="emit('open', note)"
    />
    <div v-if="otherAttachments.length" class="mb-2 flex flex-wrap gap-1">
      <span
        v-for="att in otherAttachments"
        :key="att.id"
        class="inline-flex max-w-full items-center gap-1 rounded-md bg-black/5 px-1.5 py-0.5 text-xs text-neutral-500 dark:bg-white/10 dark:text-neutral-400"
      >
        <Icon name="paperclip" />
        <span class="max-w-[140px] truncate">{{ att.filename || "file" }}</span>
      </span>
    </div>


    <!-- One render path: every note is a body plus, maybe, checkable items.
         A focusable div rather than a <button>, because a checklist nests interactive
         controls and those cannot live inside a button — and the card is the same
         shape whether or not it happens to carry items today. -->
    <div
      role="button"
      tabindex="0"
      class="cursor-text rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
      @click="emit('open', note)"
      @keydown.enter="emit('open', note)"
    >
      <!-- A lone URL renders as its preview and nothing else. Until the fetch lands
           — or if it never does — the URL itself stands in, so the card is never
           blank and the link is never unreachable. -->
      <LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" />
      <div v-else-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
        <MarkdownText :text="bodyPreview" :tag-colors="tagColors" toggleable @toggle="toggleTask" />
      </div>
      <p
        v-if="!note.body && !note.items.length && !note.attachments.length"
        class="text-sm italic text-neutral-400"
      >
        Empty note
      </p>
    </div>
    <!-- Inline links: a compact strip at the FOOT of the card, under the note's own
         words rather than stacked on top of them. They were above the body until
         M13 — which put a stranger's headline where the note's first line should be. -->
    <div v-if="!isLoneUrl && note.previews.length" class="mt-2 flex flex-col gap-1">
      <LinkPreview v-for="p in note.previews" :key="p.id" :preview="p" compact />
    </div>

    <!-- No separate checklist block any more. A checklist is lines of the body (M304),
         so MarkdownText above draws it in place — which is what lets a list sit between
         two paragraphs instead of always after them. Rendering both would have shown
         every list twice. -->

    <div v-if="note.remind_at" class="mt-2 flex flex-wrap items-center gap-1.5">
      <span
        class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
        :class="
          isOverdue(note.remind_at)
            ? 'bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300'
            : 'bg-black/5 text-neutral-600 dark:bg-white/10 dark:text-neutral-300'
        "
      >
        <svg
          class="h-3 w-3"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
        >
          <circle cx="12" cy="12" r="10" />
          <polyline points="12 6 12 12 16 14" />
        </svg>
        {{ formatReminder(note.remind_at) }}
        <!-- Recurrence was only ever shown in the reminders list; carried onto the
             card so unifying them didn't quietly drop it, and so a repeating note
             reads as repeating on the board too. -->
        <span v-if="note.recurrence" :title="`Repeats ${note.recurrence}`">· ↻ {{ note.recurrence }}</span>
      </span>

      <!-- Clearing a reminder is UPKEEP, and upkeep that costs three clicks is
           upkeep people stop doing — so it happens right here rather than by
           opening the note. Shown wherever a note carries a reminder, the board
           included, not only in the reminders lens.
           Always visible rather than hover-revealed: a finger cannot hover, and
           these are the primary action for a due note (task 2697). -->
      <button type="button" class="chip-btn" title="Mark this reminder done" @click.stop="completeReminder">
        Done
      </button>
      <button type="button" class="chip-btn" title="Snooze 1 hour" @click.stop="snoozeReminder(60)">1h</button>
      <button type="button" class="chip-btn" title="Snooze 1 day" @click.stop="snoozeReminder(1440)">1d</button>
    </div>

    <div v-if="trashCountdown" class="mt-2">
      <span
        class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
        :class="
          trashUrgent
            ? 'bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300'
            : 'bg-black/5 text-neutral-600 dark:bg-white/10 dark:text-neutral-300'
        "
        :title="`Permanently deleted ${config.trashRetentionDays} days after it was trashed`"
      >
        <svg
          class="h-3 w-3"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
        >
          <circle cx="12" cy="12" r="10" />
          <polyline points="12 6 12 12 16 14" />
        </svg>
        {{ trashCountdown }}
      </span>
    </div>

    <!-- The card's controls: the drag grip, then the action set.
         Placement is CSS's job, not the markup's — `.note-actions` in style.css puts
         them where the device can actually use them. Where a pointer hovers they lift
         out of flow into the floating top-corner pills they have always been; where
         nothing can hover they stay here, in flow, as a footer row. The alternative
         (a permanently visible overlay) sat on top of the note's own title, which is
         what a phone was showing.
         `hover-reveal` still handles the visibility half — see style.css. -->
    <div class="note-actions">
      <!-- Reorder is gated behind this grip so a normal click or tap never starts a
           drag. Board views only (reorderable). A pointer-only affordance — there is
           no keyboard equivalent, hence tabindex="-1". `touch-none` hands the gesture
           to us instead of the browser's scrolling. -->
      <button
        v-if="canDrag()"
        type="button"
        tabindex="-1"
        class="note-grip hover-reveal pointer-events-none flex touch-none cursor-grab items-center rounded-full bg-white/85 p-1 text-neutral-500 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition hover:text-neutral-800 active:cursor-grabbing group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:text-neutral-400 dark:ring-white/10 dark:hover:text-neutral-100"
        title="Drag to reorder"
        aria-label="Drag to reorder"
        @pointerdown="onGripDown"
        @pointermove="onGripMove"
        @pointerup="onGripUp"
        @pointercancel="onGripCancel"
      >
        <Icon name="grip" />
      </button>

      <div
        class="note-actions-set hover-reveal pointer-events-none flex items-center gap-0.5 rounded-full bg-white/85 p-0.5 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition focus-within:pointer-events-auto focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:ring-white/10"
      >
        <template v-if="note.trashed">
          <button type="button" class="icon-btn" title="Restore" aria-label="Restore" @click="notes.restore(note.id)">
            <Icon name="restore" />
          </button>
          <button
            type="button"
            class="icon-btn"
            title="Delete forever"
            aria-label="Delete forever"
            @click="notes.deleteForever(note.id)"
          >
            <Icon name="trash" />
          </button>
        </template>
        <template v-else>
          <button
            type="button"
            class="icon-btn"
            :class="note.pinned ? 'text-brand-700 dark:text-brand' : ''"
            :title="note.pinned ? 'Unpin' : 'Pin'"
            :aria-label="note.pinned ? 'Unpin' : 'Pin'"
            :aria-pressed="note.pinned"
            @click="notes.setPinned(note.id, !note.pinned)"
          >
            <Icon name="pin" />
          </button>
          <button
            type="button"
            class="icon-btn"
            :title="note.archived ? 'Unarchive' : 'Archive'"
            :aria-label="note.archived ? 'Unarchive' : 'Archive'"
            @click="notes.setArchived(note.id, !note.archived)"
          >
            <Icon name="archive" />
          </button>
          <button
            type="button"
            class="icon-btn"
            title="Move to trash"
            aria-label="Move to trash"
            @click="notes.trash(note.id)"
          >
            <Icon name="trash" />
          </button>
        </template>
      </div>
    </div>
  </div>
</template>
