<script lang="ts">
	import { onDestroy } from 'svelte';
	import MarkdownIt from 'markdown-it';

	// Safe by construction: `html: false` escapes raw HTML in the
	// source, so `{@html}` below only ever injects markdown-it's own
	// output. Links open in a new tab so navigation doesn't blow away
	// the PWA's in-memory session state.
	const md = new MarkdownIt({ html: false, linkify: true });
	const defaultLink =
		md.renderer.rules.link_open ?? ((tokens, idx, options, _env, self) => self.renderToken(tokens, idx, options));
	md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
		tokens[idx]?.attrSet('target', '_blank');
		tokens[idx]?.attrSet('rel', 'noopener noreferrer');
		return defaultLink(tokens, idx, options, env, self);
	};

	// Autolink GitHub-style issue/PR references (`#123`) in plain
	// text to `<repoUrl>/issues/123` (GitHub redirects to /pull for
	// PRs). Runs as a core rule over the parsed token stream, so it
	// only touches `text` tokens — references inside existing
	// markdown links or code spans/fences are left alone by
	// construction. `repoUrl` rides in via the render env; no repo,
	// no rule.
	md.core.ruler.push('issue_refs', (state) => {
		const repoUrl: unknown = (state.env as Record<string, unknown>).repoUrl;
		if (typeof repoUrl !== 'string' || repoUrl.length === 0) {
			return;
		}
		for (const block of state.tokens) {
			if (block.type !== 'inline' || !block.children) {
				continue;
			}
			let linkDepth = 0;
			const out: (typeof block.children)[number][] = [];
			for (const tok of block.children) {
				if (tok.type === 'link_open') {
					linkDepth++;
				} else if (tok.type === 'link_close') {
					linkDepth--;
				}
				if (tok.type !== 'text' || linkDepth > 0 || !/#\d+/.test(tok.content)) {
					out.push(tok);
					continue;
				}
				// Split on standalone `#123` (not `abc#123`, not `#123x`).
				let rest = tok.content;
				const re = /(^|[^\w#])#(\d+)(?![\w#])/;
				let m = re.exec(rest);
				while (m !== null) {
					const before = rest.slice(0, m.index) + (m[1] ?? '');
					if (before) {
						const t = new state.Token('text', '', 0);
						t.content = before;
						out.push(t);
					}
					const open = new state.Token('link_open', 'a', 1);
					open.attrSet('href', `${repoUrl}/issues/${m[2]}`);
					out.push(open);
					const label = new state.Token('text', '', 0);
					label.content = `#${m[2]}`;
					out.push(label);
					out.push(new state.Token('link_close', 'a', -1));
					rest = rest.slice(m.index + m[0].length);
					m = re.exec(rest);
				}
				if (rest) {
					const t = new state.Token('text', '', 0);
					t.content = rest;
					out.push(t);
				}
			}
			block.children = out;
		}
	});

	type Props = { text: string; repoUrl?: string | null };
	let { text, repoUrl = null }: Props = $props();

	// rAF-coalesced render: streaming deltas arrive faster than
	// frames, and re-parsing + rewriting innerHTML per delta would
	// jank a phone. One parse per frame, always of the latest text.
	// (The desktop goes further with per-block caching — see
	// `src/lib/markdown.ts`; phone-scale messages don't need it.)
	let html = $state('');
	let pending = '';
	let frame: number | null = null;

	let pendingRepoUrl: string | null = null;
	$effect(() => {
		pending = text;
		// Read synchronously so the effect *tracks* repoUrl: the SCM
		// status (and thus the repo URL) often resolves after a
		// replayed transcript rendered, and without this dependency
		// the already-rendered bubbles would never gain their #123
		// links.
		pendingRepoUrl = repoUrl;
		if (frame !== null) {
			return;
		}
		frame = requestAnimationFrame(() => {
			frame = null;
			html = md.render(pending, { repoUrl: pendingRepoUrl });
		});
	});

	onDestroy(() => {
		if (frame !== null) {
			cancelAnimationFrame(frame);
		}
	});
</script>

<div class="md">
	<!-- eslint-disable-next-line svelte/no-at-html-tags -- markdown-it output with html:false, see above -->
	{@html html}
</div>

<style>
	.md :global(> :first-child) {
		margin-top: 0;
	}
	.md :global(> :last-child) {
		margin-bottom: 0;
	}
	.md :global(p),
	.md :global(ul),
	.md :global(ol),
	.md :global(pre),
	.md :global(blockquote) {
		margin: 0.4rem 0;
	}
	.md :global(h1),
	.md :global(h2),
	.md :global(h3),
	.md :global(h4) {
		margin: 0.6rem 0 0.3rem;
		font-size: 1rem;
	}
	.md :global(ul),
	.md :global(ol) {
		padding-left: 1.2rem;
	}
	.md :global(code) {
		font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
		font-size: 0.85em;
		background: var(--bg-elev-2);
		border-radius: 4px;
		padding: 0.1em 0.3em;
	}
	.md :global(pre) {
		background: var(--bg-elev-2);
		border: 1px solid var(--border);
		border-radius: var(--radius);
		padding: 0.5rem;
		overflow-x: auto;
	}
	.md :global(pre code) {
		background: none;
		padding: 0;
		font-size: 0.75rem;
	}
	.md :global(blockquote) {
		border-left: 3px solid var(--border);
		padding-left: 0.6rem;
		color: var(--fg-muted);
	}
	.md :global(table) {
		border-collapse: collapse;
		display: block;
		overflow-x: auto;
		font-size: 0.85em;
	}
	.md :global(th),
	.md :global(td) {
		border: 1px solid var(--border);
		padding: 0.2rem 0.5rem;
	}
	.md :global(hr) {
		border: none;
		border-top: 1px solid var(--border);
	}
</style>
