import { describe, it, expect } from 'vitest';
import {
	detectMention,
	escapeHtml,
	extractContent,
	renderBody,
	renderTranscriptionBody
} from './mention';
import type { MentionDTO, PersonMention } from '$lib/shared/types';

// ─── escapeHtml ───────────────────────────────────────────────────────────────

describe('escapeHtml', () => {
	it('escapes ampersand', () => {
		expect(escapeHtml('AT&T')).toBe('AT&amp;T');
	});

	it('escapes less-than and greater-than', () => {
		expect(escapeHtml('<script>')).toBe('&lt;script&gt;');
	});

	it('escapes double quote', () => {
		expect(escapeHtml('say "hi"')).toBe('say &quot;hi&quot;');
	});

	it('returns empty string unchanged', () => {
		expect(escapeHtml('')).toBe('');
	});

	it('escapes ampersand before other entities to avoid double-encoding', () => {
		expect(escapeHtml('a&<b')).toBe('a&amp;&lt;b');
	});

	it('escapes apostrophe to &#39;', () => {
		expect(escapeHtml("d'Artagnan")).toBe('d&#39;Artagnan');
	});

	it('does not collapse already-encoded entities (re-escapes the &)', () => {
		// escapeHtml is idempotent by composition: the second pass re-escapes
		// the & that was added by the first. Pin the property so the helper
		// can't be "cleverly" optimised to skip it.
		expect(escapeHtml('&amp;')).toBe('&amp;amp;');
	});
});

// ─── detectMention ────────────────────────────────────────────────────────────

describe('detectMention', () => {
	it('returns null when text has no @', () => {
		expect(detectMention('hello world', 11)).toBeNull();
	});

	it('returns null when @ is not the most recent trigger word', () => {
		// cursor is past a completed mention (next word started)
		expect(detectMention('hello @Hans Müller more', 22)).toBeNull();
	});

	it('returns empty string immediately after @', () => {
		expect(detectMention('hello @', 7)).toBe('');
	});

	it('returns query text after @', () => {
		expect(detectMention('hello @Han', 10)).toBe('Han');
	});

	it('returns null when @ is preceded by a letter (email address pattern)', () => {
		expect(detectMention('user@example', 12)).toBeNull();
	});

	it('returns query for @ at the very start of string', () => {
		expect(detectMention('@Hans', 5)).toBe('Hans');
	});

	it('returns null when cursor is before the @', () => {
		expect(detectMention('@Hans', 0)).toBeNull();
	});
});

// ─── extractContent ───────────────────────────────────────────────────────────

describe('extractContent', () => {
	it('returns empty arrays for empty string', () => {
		const result = extractContent('', []);
		expect(result.content).toBe('');
		expect(result.mentionedUserIds).toEqual([]);
	});

	it('returns plain content unchanged when no candidates', () => {
		const result = extractContent('Hello world', []);
		expect(result.content).toBe('Hello world');
		expect(result.mentionedUserIds).toEqual([]);
	});

	it('extracts user id when @FirstName LastName is in content', () => {
		const candidates: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
		const result = extractContent('Hey @Hans Müller how are you?', candidates);
		expect(result.mentionedUserIds).toContain('uuid-1');
	});

	it('deduplicates user ids when same user mentioned twice', () => {
		const candidates: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
		const result = extractContent('@Hans Müller and @Hans Müller again', candidates);
		expect(result.mentionedUserIds).toHaveLength(1);
		expect(result.mentionedUserIds).toContain('uuid-1');
	});

	it('collects multiple distinct users', () => {
		const candidates: MentionDTO[] = [
			{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' },
			{ id: 'uuid-2', firstName: 'Anna', lastName: 'Schmidt' }
		];
		const result = extractContent('@Hans Müller and @Anna Schmidt', candidates);
		expect(result.mentionedUserIds).toContain('uuid-1');
		expect(result.mentionedUserIds).toContain('uuid-2');
	});
});

// ─── renderBody ───────────────────────────────────────────────────────────────

describe('renderBody', () => {
	it('returns escaped plain text when no mentions', () => {
		expect(renderBody('Hello world', [])).toBe('Hello world');
	});

	it('escapes < and > in content', () => {
		const result = renderBody('<script>alert(1)</script>', []);
		expect(result).toContain('&lt;script&gt;');
		expect(result).not.toContain('<script>');
	});

	it('escapes & in content', () => {
		const result = renderBody('AT&T', []);
		expect(result).toContain('AT&amp;T');
	});

	it('wraps @mention in a mention span', () => {
		const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
		const result = renderBody('Hey @Hans Müller!', mentions);
		expect(result).toContain('<span');
		expect(result).toContain('class="mention"');
		expect(result).toContain('Hans Müller');
	});

	it('does not double-encode already escaped text', () => {
		const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
		const result = renderBody('Check @Hans Müller', mentions);
		expect(result).not.toContain('&amp;');
	});

	it('replaces all occurrences of the same mention', () => {
		const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
		const result = renderBody('@Hans Müller and @Hans Müller', mentions);
		const spanCount = (result.match(/<span /g) ?? []).length;
		expect(spanCount).toBe(2);
	});

	it('escapes HTML special chars in mention display names', () => {
		const mentions: MentionDTO[] = [{ id: 'u1', firstName: '<script>', lastName: 'alert(1)' }];
		const result = renderBody('@<script> alert(1)', mentions);
		expect(result).not.toContain('<script>');
		expect(result).toContain('&lt;script&gt;');
	});

	it('converts newlines to <br>', () => {
		const result = renderBody('line1\nline2', []);
		expect(result).toContain('<br>');
		expect(result).not.toContain('\n');
	});
});

// ─── renderTranscriptionBody ──────────────────────────────────────────────────

describe('renderTranscriptionBody', () => {
	const auguste: PersonMention = {
		personId: '550e8400-e29b-41d4-a716-446655440000',
		displayName: 'Auguste Raddatz'
	};
	const hans: PersonMention = {
		personId: '550e8400-e29b-41d4-a716-446655440001',
		displayName: 'Hans'
	};

	it('returns empty string for empty input', () => {
		expect(renderTranscriptionBody('', [])).toBe('');
	});

	it('returns escaped plain text when no mentions', () => {
		expect(renderTranscriptionBody('Hello world', [])).toBe('Hello world');
	});

	it('escapes < and > in plain block text', () => {
		const result = renderTranscriptionBody('<script>alert(1)</script>', []);
		expect(result).toBe('&lt;script&gt;alert(1)&lt;/script&gt;');
		expect(result).not.toContain('<script>');
	});

	it('escapes & in plain block text', () => {
		expect(renderTranscriptionBody('AT&T', [])).toBe('AT&amp;T');
	});

	it('replaces @DisplayName with anchor link to /persons/{personId}', () => {
		const result = renderTranscriptionBody('Brief an @Auguste Raddatz vom Mai', [auguste]);
		expect(result).toContain(`<a href="/persons/${auguste.personId}"`);
		expect(result).toContain('class="person-mention"');
		expect(result).toContain(`data-person-id="${auguste.personId}"`);
		expect(result).toContain('>Auguste Raddatz</a>');
	});

	it('strips the @ prefix from rendered link text (read mode)', () => {
		const result = renderTranscriptionBody('Hallo @Auguste Raddatz!', [auguste]);
		// The anchor body is the bare display name — no leading @
		expect(result).not.toMatch(/>@Auguste Raddatz</);
		expect(result).toMatch(/>Auguste Raddatz</);
	});

	it('removes the trigger @ from the surrounding text (no orphan @ before the link)', () => {
		const result = renderTranscriptionBody('Brief an @Auguste Raddatz vom Mai', [auguste]);
		// No bare @ remains where the mention was
		expect(result).not.toMatch(/@<a/);
	});

	it('replaces all occurrences of the same mention', () => {
		const result = renderTranscriptionBody('@Auguste Raddatz und @Auguste Raddatz', [auguste]);
		const anchorCount = (result.match(/<a /g) ?? []).length;
		expect(anchorCount).toBe(2);
	});

	it('does not replace plain-text occurrences without the @ trigger', () => {
		const result = renderTranscriptionBody('Auguste Raddatz war hier', [auguste]);
		expect(result).not.toContain('<a ');
		expect(result).toBe('Auguste Raddatz war hier');
	});

	it('processes longer displayNames first to avoid prefix shadowing', () => {
		const SHORT_ID = '11111111-1111-4111-8111-111111111111';
		const LONG_ID = '22222222-2222-4222-8222-222222222222';
		const augusteShort: PersonMention = { personId: SHORT_ID, displayName: 'Auguste' };
		const augusteLong: PersonMention = {
			personId: LONG_ID,
			displayName: 'Auguste Raddatz'
		};
		// Sidecar order is short-first; longer match must still win for the long text
		const result = renderTranscriptionBody('@Auguste Raddatz schreibt @Auguste', [
			augusteShort,
			augusteLong
		]);
		expect(result).toContain(`href="/persons/${LONG_ID}"`);
		expect(result).toContain(`href="/persons/${SHORT_ID}"`);
		// The "Raddatz" suffix must not leak inside the short-name anchor
		expect(result).not.toMatch(/>Auguste<\/a> Raddatz/);
	});

	it('does not match @ followed by extra word characters (word boundary)', () => {
		// Sidecar contains "Hans"; text contains "@HansMüller" — no link.
		const result = renderTranscriptionBody('Brief an @HansMüller', [hans]);
		expect(result).not.toContain('<a ');
		expect(result).toContain('@HansM');
	});

	it('first-sidecar-wins when two entries share the same displayName', () => {
		// Two persons named "Hans" — first sidecar entry wins for all occurrences.
		const FIRST_ID = '33333333-3333-4333-8333-333333333333';
		const SECOND_ID = '44444444-4444-4444-8444-444444444444';
		const hansFirst: PersonMention = { personId: FIRST_ID, displayName: 'Hans' };
		const hansSecond: PersonMention = { personId: SECOND_ID, displayName: 'Hans' };
		const result = renderTranscriptionBody('@Hans und @Hans', [hansFirst, hansSecond]);
		expect(result).toContain(`href="/persons/${FIRST_ID}"`);
		expect(result).not.toContain(`href="/persons/${SECOND_ID}"`);
		const anchorCount = (result.match(/<a /g) ?? []).length;
		expect(anchorCount).toBe(2);
	});

	it('escapes HTML in displayName to prevent stored XSS', () => {
		const xss: PersonMention = {
			personId: '55555555-5555-4555-8555-555555555555',
			displayName: '<script>alert(1)</script>'
		};
		const result = renderTranscriptionBody('Hi @<script>alert(1)</script> there', [xss]);
		expect(result).not.toContain('<script>');
		expect(result).toContain('&lt;script&gt;');
	});

	it('escapes <img onerror=...> payloads in surrounding block text', () => {
		const result = renderTranscriptionBody('<img src=x onerror=alert(1)> hello', []);
		expect(result).not.toContain('<img');
		expect(result).toContain('&lt;img');
	});

	it('does not double-encode HTML-entity-already-encoded payloads', () => {
		// `&amp;lt;script&amp;gt;` is already-escaped HTML in the source text.
		// renderTranscriptionBody must escape the literal & once → `&amp;amp;lt;...`
		// — never silently decode pre-escaped entities.
		const result = renderTranscriptionBody('text &amp;lt;script&amp;gt;', []);
		expect(result).toBe('text &amp;amp;lt;script&amp;amp;gt;');
	});

	it('escapes quotes in displayName so they cannot break the href attribute', () => {
		const tricky: PersonMention = {
			personId: '66666666-6666-4666-8666-666666666666',
			displayName: 'O"Brien'
		};
		const result = renderTranscriptionBody('@O"Brien', [tricky]);
		// The raw `"` from the displayName must never appear inside the rendered link
		// — it would terminate the attribute value early and let an attacker craft
		// arbitrary attributes on the anchor. It must arrive at the browser as &quot;.
		expect(result).toMatch(/>O&quot;Brien<\/a>/);
		expect(result).not.toMatch(/>O"Brien<\/a>/);
	});

	it('renders a deleted-person @mention as plain text with no dead link (graceful degradation)', () => {
		// AC-6 (#684): when a mentioned person is deleted, V71's ON DELETE CASCADE removes the
		// sidecar row, so the displayName reaches the renderer with an empty mentionedPersons
		// array. The reader must still see the name as plain text — never a dead <a>, a
		// person-mention class, a data-person-id, or an href. This locks the degradation
		// contract so a future renderer refactor cannot silently reintroduce a dead link.
		const result = renderTranscriptionBody('Brief an @Auguste Raddatz vom Mai', []);
		// (a) the reader still sees the readable name and the surrounding sentence verbatim
		expect(result).toContain('Auguste Raddatz');
		expect(result).toBe('Brief an @Auguste Raddatz vom Mai');
		// (b) none of the anchor artifacts leak through
		expect(result).not.toContain('<a');
		expect(result).not.toContain('person-mention');
		expect(result).not.toContain('data-person-id');
		expect(result).not.toContain('href');
	});

	it('skips substitution when personId is not a UUID (defense in depth)', () => {
		// Nora #5551: if personId ever flowed in from a less-sanitised source
		// (a future "external person" or a bad sidecar), the renderer must not
		// emit a clickable link. The escaped text remains as plain content.
		const evil: PersonMention = {
			personId: 'javascript:alert(1)',
			displayName: 'Evil Link'
		};
		const result = renderTranscriptionBody('Hi @Evil Link!', [evil]);
		expect(result).not.toContain('<a ');
		expect(result).not.toContain('javascript:');
		// The @-trigger and displayName are preserved as plain text
		expect(result).toContain('@Evil Link');
	});

	it('skips substitution when personId is an absolute URL', () => {
		const evil: PersonMention = {
			personId: 'https://evil.example/persons/abc',
			displayName: 'Phisher'
		};
		const result = renderTranscriptionBody('Hi @Phisher', [evil]);
		expect(result).not.toContain('<a ');
		expect(result).not.toContain('https://evil.example');
	});

	it('still substitutes when personId is a well-formed UUID', () => {
		// Sanity check that the validation does not over-reject valid IDs.
		const valid: PersonMention = {
			personId: '550e8400-e29b-41d4-a716-446655440000',
			displayName: 'Auguste Raddatz'
		};
		const result = renderTranscriptionBody('Brief an @Auguste Raddatz', [valid]);
		expect(result).toContain('<a ');
		expect(result).toContain('href="/persons/550e8400-e29b-41d4-a716-446655440000"');
	});
});
