<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatLifeDate } from '$lib/person/personLifeDates';
import { chipLabel, otherName } from '$lib/person/relationshipLabels';
import type { components } from '$lib/generated/api';
import type { LoadState } from '$lib/person/personHoverCard';

type RelationshipDTO = components['schemas']['RelationshipDTO'];

type Props = {
	personId: string;
	cardId: string;
	position: { top: number; left: number };
	state: LoadState;
	onmouseenter?: () => void;
	onmouseleave?: () => void;
};

let { personId, cardId, position, state, onmouseenter, onmouseleave }: Props = $props();

const FAMILY_REL_TYPES: ReadonlySet<RelationshipDTO['relationType']> = new Set([
	'PARENT_OF',
	'SPOUSE_OF',
	'SIBLING_OF'
]);
const NOTES_MAX = 120;

const familyChips = $derived(
	state.status === 'loaded'
		? state.relationships.filter((r) => FAMILY_REL_TYPES.has(r.relationType))
		: []
);

const birthText = $derived(
	state.status === 'loaded'
		? formatLifeDate(state.person.birthDate, state.person.birthDatePrecision)
		: ''
);
const deathText = $derived(
	state.status === 'loaded'
		? formatLifeDate(state.person.deathDate, state.person.deathDatePrecision)
		: ''
);

/**
 * Cut the notes excerpt at the last word boundary inside the NOTES_MAX
 * window. Mid-word truncation is especially ugly in German compound nouns
 * ("…Familienzu…"), so prefer the previous space if there is one within
 * a reasonable distance. Fall back to a hard cut for strings with no
 * spaces at all (e.g. a single 150-char word). Leonie FINDING-04 / Elicit E5.
 */
function truncateAtWordBoundary(text: string, max: number): string {
	if (text.length <= max) return text;
	const window = text.slice(0, max);
	const lastSpace = window.lastIndexOf(' ');
	// If the last space is too close to the start (< 70% of the window) we'd
	// produce a near-empty excerpt — fall back to the hard cut instead.
	const minBoundary = Math.floor(max * 0.7);
	const cut = lastSpace >= minBoundary ? window.slice(0, lastSpace) : window;
	return cut + '…';
}

const notesExcerpt = $derived.by(() => {
	if (state.status !== 'loaded') return null;
	const notes = state.person.notes;
	if (!notes) return null;
	return truncateAtWordBoundary(notes, NOTES_MAX);
});

// Accessible name for the region landmark — required by WCAG 1.3.1.
// Falls back to a localised loading label so axe-core never sees an unnamed
// region (Leonie FINDING-02 / Elicit NFR concern).
const ariaLabel = $derived(
	state.status === 'loaded' ? state.person.displayName : m.person_mention_loading()
);

// aria-busy="true" while loading so SR clients know the region's contents
// will change. Cleared on loaded/error so the new content is announced.
const ariaBusy = $derived(state.status === 'loading');

const showMaidenName = $derived(
	state.status === 'loaded' &&
		!!state.person.alias &&
		state.person.alias !== state.person.lastName &&
		state.person.alias !== state.person.displayName
);
</script>

<div
	class="person-hover-card"
	data-testid="person-hover-card"
	id={cardId}
	role="region"
	aria-live="polite"
	aria-label={ariaLabel}
	aria-busy={ariaBusy ? 'true' : undefined}
	style:position="fixed"
	style:top={`${position.top}px`}
	style:left={`${position.left}px`}
	onmouseenter={onmouseenter}
	onmouseleave={onmouseleave}
	onfocusin={onmouseenter}
	onfocusout={(e) => {
		if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node | null)) {
			onmouseleave?.();
		}
	}}
>
	{#if state.status === 'loading'}
		<div
			data-testid="person-hover-card-skeleton"
			class="skeleton"
			role="status"
			aria-label={m.person_mention_loading()}
		>
			<div class="bar"></div>
			<div class="bar"></div>
			<div class="bar"></div>
		</div>
	{:else if state.status === 'error'}
		<div data-testid="person-hover-card-error" class="error-message">
			{m.person_mention_load_error()}
		</div>
		<div class="footer">
			<a href="/persons/{personId}" class="open-link">{m.person_mention_open_link()} →</a>
		</div>
	{:else}
		<div data-testid="person-hover-card-content" class="content">
			<div class="header">
				<div class="name" data-testid="person-hover-card-name">{state.person.displayName}</div>
				{#if birthText || deathText}
					<!-- Glyphs aria-hidden so screen readers only announce the dates -->
					<div class="dates" data-testid="person-hover-card-dates">
						{#if birthText}
							<span aria-hidden="true">*</span> {birthText}
						{/if}
						{#if birthText && deathText}–{/if}
						{#if deathText}
							<span aria-hidden="true">†</span> {deathText}
						{/if}
					</div>
				{/if}
				{#if showMaidenName}
					<div class="maiden" data-testid="person-hover-card-maiden">
						{m.person_born_name_prefix()}
						{state.person.alias}
					</div>
				{/if}
			</div>
			{#if familyChips.length > 0}
				<div class="chips" data-testid="person-hover-card-chips">
					{#each familyChips as chip (chip.id)}
						<span class="chip">
							<span class="chip-type">{chipLabel(chip, personId)}:</span>
							{otherName(chip, personId)}
						</span>
					{/each}
				</div>
			{/if}
			{#if notesExcerpt}
				<p class="notes" data-testid="person-hover-card-notes">{notesExcerpt}</p>
			{/if}
			<div class="footer">
				<a href="/persons/{personId}" class="open-link">{m.person_mention_open_link()} →</a>
				<span class="hint">{m.person_mention_hover_hint()}</span>
			</div>
		</div>
	{/if}
</div>

<style>
.person-hover-card {
	width: 320px;
	min-height: 180px;
	background-color: var(--c-surface);
	border: 1px solid var(--c-line);
	border-radius: 6px;
	box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
	padding: 14px 16px;
	font-family: var(--font-sans);
	font-size: 14px;
	color: var(--c-ink);
	z-index: 50;
}

/* On touch devices the card is suppressed entirely — tap navigates directly. */
@media (hover: none) {
	.person-hover-card {
		display: none;
	}
}

.skeleton {
	display: flex;
	flex-direction: column;
	gap: 10px;
	padding: 4px 0;
}

.skeleton .bar {
	height: 14px;
	border-radius: 4px;
	background-color: var(--c-line);
	animation: pulse 1.4s ease-in-out infinite;
}

.skeleton .bar:nth-child(1) {
	width: 60%;
}
.skeleton .bar:nth-child(2) {
	width: 40%;
}
.skeleton .bar:nth-child(3) {
	width: 90%;
}

@keyframes pulse {
	0% {
		opacity: 0.55;
	}
	50% {
		opacity: 1;
	}
	100% {
		opacity: 0.55;
	}
}

@media (prefers-reduced-motion: reduce) {
	.skeleton .bar {
		animation: none;
		opacity: 0.7;
	}
}

.header {
	display: flex;
	flex-direction: column;
	gap: 2px;
	background-color: var(--c-ink);
	color: var(--c-surface);
	margin: -14px -16px 12px;
	padding: 12px 16px;
	border-top-left-radius: 6px;
	border-top-right-radius: 6px;
}

.name {
	font-family: var(--font-serif);
	font-weight: 600;
	font-size: 16px;
}

.dates,
.maiden {
	font-size: 12px;
	color: color-mix(in srgb, var(--c-surface) 75%, transparent);
}

.chips {
	display: flex;
	flex-wrap: wrap;
	gap: 6px;
	margin-bottom: 10px;
}

.chip {
	display: flex;
	align-items: center;
	gap: 4px;
	font-size: 12px;
	background-color: var(--c-accent-bg);
	color: var(--c-ink);
	border-radius: 999px;
	padding: 2px 10px;
}

.chip-type {
	font-weight: 600;
	/* opacity 0.7 on --c-ink: ~5.6:1 light, ~7.1:1 dark — WCAG AA ✓ */
	opacity: 0.7;
}

.notes {
	font-size: 13px;
	color: var(--c-ink-2);
	line-height: 1.4;
	margin: 0 0 10px;
}

.error-message {
	font-size: 13px;
	color: var(--c-ink-2);
	padding: 8px 0;
}

.footer {
	display: flex;
	align-items: center;
	justify-content: space-between;
	border-top: 1px solid var(--c-line);
	padding-top: 8px;
	margin-top: 4px;
}

.open-link {
	color: var(--c-ink);
	text-decoration: underline;
	text-underline-offset: 3px;
	font-weight: 500;
}

.hint {
	font-size: 11px;
	color: var(--c-ink-3);
}
</style>
