import type { Note, PinPosition } from '../api/notes';

/**
 * Pure helpers for notes & comments (T169): mentions, threads, the polling merge, pin geometry, deep links.
 * Kept free of Vue / Pinia so they are unit-tested directly.
 */

/** Same rule as `Zadex\Team\Mentions::PATTERN`: `@slug`, not inside an email address. */
const MENTION_RE = /(?<![\w@.])@([A-Za-z0-9_][A-Za-z0-9_.-]{0,59})/g;

const trimSlug = (s: string): string => s.replace(/[.-]+$/, '');

export function mentionSlugs(text: string): string[] {
  const out: string[] = [];
  for (const m of text.matchAll(MENTION_RE)) {
    const slug = trimSlug(m[1]);
    if (slug && !out.includes(slug)) out.push(slug);
  }
  return out;
}

export interface Segment {
  text: string;
  /** Set when this segment is an @mention (the slug without `@`). */
  mention?: string;
}

/** Splits a note into text and @mention segments (rendered as text nodes: notes are never HTML). */
export function mentionSegments(text: string): Segment[] {
  const out: Segment[] = [];
  let last = 0;
  for (const m of text.matchAll(MENTION_RE)) {
    const slug = trimSlug(m[1]);
    const start = m.index ?? 0;
    const end = start + 1 + slug.length;
    if (start > last) out.push({ text: text.slice(last, start) });
    out.push({ text: text.slice(start, end), mention: slug });
    last = end;
  }
  if (last < text.length) out.push({ text: text.slice(last) });
  return out;
}

/** The `@query` being typed right before the caret, or null. `start` is the index of `@`. */
export function mentionQuery(text: string, caret: number): { start: number; query: string } | null {
  const before = text.slice(0, caret);
  const m = /(?:^|[^\w@.])@([A-Za-z0-9_.-]{0,60})$/.exec(before);
  if (!m) return null;
  return { start: caret - m[1].length - 1, query: m[1] };
}

/** Replaces the `@query` at `start..caret` with `@slug ` and returns the new text and caret. */
export function insertMention(text: string, start: number, caret: number, slug: string): { text: string; caret: number } {
  const insert = `@${slug} `;
  return { text: text.slice(0, start) + insert + text.slice(caret), caret: start + insert.length };
}

const byTime = (a: Note, b: Note): number => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : a.id - b.id);

export function threadsOf(notes: readonly Note[]): Note[] {
  return notes.filter((n) => n.parentId === 0).sort(byTime);
}

export function repliesOf(notes: readonly Note[], rootId: number): Note[] {
  return notes.filter((n) => n.parentId === rootId).sort(byTime);
}

/**
 * Polling merge: the server list is authoritative (new notes appear, deleted ones go, edits win), except
 * optimistic local notes still waiting for their POST (negative ids), which are kept. Unchanged notes keep
 * their local object so open popovers do not re-render. `changed` is false when nothing differs.
 */
export function mergeNotes(local: readonly Note[], remote: readonly Note[]): { notes: Note[]; changed: boolean } {
  const byId = new Map(local.map((n) => [n.id, n]));
  let changed = false;
  const out: Note[] = remote.map((r) => {
    const l = byId.get(r.id);
    if (l && l.updatedAt === r.updatedAt && l.status === r.status && l.content === r.content) return l;
    changed = true;
    return r;
  });
  const remoteIds = new Set(remote.map((r) => r.id));
  for (const l of local) {
    if (l.id < 0) out.push(l);
    else if (!remoteIds.has(l.id)) changed = true;
  }
  if (out.length !== local.length) changed = true;
  return { notes: out.sort(byTime), changed };
}

export interface Box {
  left: number;
  top: number;
  width: number;
  height: number;
}

const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 0);
const round4 = (v: number): number => Math.round(v * 10000) / 10000;

/** Where a click landed inside the anchor box, as a 0..1 offset (what the note stores). */
export function pinOffset(point: { x: number; y: number }, box: Box): PinPosition {
  return {
    x: round4(box.width > 0 ? clamp01((point.x - box.left) / box.width) : 0),
    y: round4(box.height > 0 ? clamp01((point.y - box.top) / box.height) : 0),
  };
}

/** The pin's viewport point for the anchor's current box (follows scroll / resize / layout changes). */
export function pinPoint(box: Box, position: PinPosition | null): { x: number; y: number } {
  const p = position ?? { x: 0, y: 0 };
  return { x: box.left + clamp01(p.x) * box.width, y: box.top + clamp01(p.y) * box.height };
}

/** Keeps a popover of size w×h inside the viewport, preferring the right of / below the anchor point. */
export function placePopover(point: { x: number; y: number }, size: { w: number; h: number }, viewport: { w: number; h: number }, gap = 12): { left: number; top: number } {
  let left = point.x + gap;
  if (left + size.w > viewport.w - 8) left = point.x - gap - size.w;
  let top = point.y - 16;
  if (top + size.h > viewport.h - 8) top = viewport.h - 8 - size.h;
  return { left: Math.max(8, left), top: Math.max(8, top) };
}

/** The editor URL that opens and focuses a thread (`?zadex=edit&note=<id>`; the harness: `/dev/?note=<id>`). */
export function noteLink(href: string, id: number): string {
  const url = new URL(href);
  url.searchParams.set('note', String(id));
  url.searchParams.delete('fresh'); // dev harness flag that would wipe the (mock) notes
  url.hash = '';
  return url.toString();
}

export function noteIdFromSearch(search: string): number | null {
  const v = Number(new URLSearchParams(search).get('note'));
  return Number.isInteger(v) && v > 0 ? v : null;
}

/** "5 minutes ago" in the editor locale (Intl, so no strings to translate). */
export function timeAgo(iso: string, now: number = Date.now(), locale = 'en'): string {
  const then = Date.parse(iso);
  if (!Number.isFinite(then)) return '';
  const s = Math.round((then - now) / 1000);
  const abs = Math.abs(s);
  let rtf: Intl.RelativeTimeFormat;
  try {
    rtf = new Intl.RelativeTimeFormat(locale.replace('_', '-'), { numeric: 'auto' });
  } catch {
    rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
  }
  if (abs < 45) return rtf.format(0, 'second');
  if (abs < 3600) return rtf.format(Math.round(s / 60), 'minute');
  if (abs < 86400) return rtf.format(Math.round(s / 3600), 'hour');
  if (abs < 86400 * 30) return rtf.format(Math.round(s / 86400), 'day');
  return new Date(then).toLocaleDateString(locale.replace('_', '-'));
}

/** Initials for the author chip. */
export function initials(name: string): string {
  const parts = name.trim().split(/\s+/).filter(Boolean);
  return ((parts[0]?.[0] ?? '?') + (parts.length > 1 ? parts[parts.length - 1][0] : '')).toUpperCase();
}
