/**
 * <folk-notes-app> — vault browser for rNotes.
 *
 * Replaces the old TipTap editor with a read-only vault sync browser.
 * Obsidian / Logseq vaults are uploaded as ZIP files, parsed server-side,
 * and stored in Automerge + on-disk. This component is the browse UI.
 *
 * Left sidebar : vault list  (fetches /api/vault/list?space=)
 * Center panel : file tree   (fetches /api/vault/:id/notes?space=)
 * Right panel  : MD preview  (fetches /api/vault/:id/note/:path?space=)
 */

import type { VaultMeta, VaultNoteMeta } from '../schemas';

// ── Tiny markdown → HTML converter (no dependencies) ─────────────────────────

function renderMarkdown(md: string): string {
	// Escape HTML in code blocks first, then restore placeholders
	const codeBlocks: string[] = [];
	let html = md.replace(/```[\s\S]*?```/g, (m) => {
		const lang = m.match(/^```(\w*)/)?.[1] ?? '';
		const code = m.replace(/^```\w*\n?/, '').replace(/\n?```$/, '');
		const escaped = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
		codeBlocks.push(`<pre><code class="language-${lang}">${escaped}</code></pre>`);
		return `\x00CB${codeBlocks.length - 1}\x00`;
	});

	// Inline code
	const inlineCodes: string[] = [];
	html = html.replace(/`([^`\n]+)`/g, (_, c) => {
		const escaped = c.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
		inlineCodes.push(`<code>${escaped}</code>`);
		return `\x00IC${inlineCodes.length - 1}\x00`;
	});

	// Wikilinks [[target]] or [[target|alias]]
	html = html.replace(/\[\[([^\]]+)\]\]/g, (_, inner) => {
		const [target, alias] = inner.split('|');
		const label = alias ?? target;
		return `<a class="wikilink" data-target="${target.trim()}" href="#">${label.trim()}</a>`;
	});

	// Images ![alt](url)
	html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img alt="$1" src="$2" style="max-width:100%">');
	// Links [text](url)
	html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');

	// Process line-by-line
	const lines = html.split('\n');
	const out: string[] = [];
	let inUl = false;
	let inOl = false;
	let inBlockquote = false;

	function closeList() {
		if (inUl) { out.push('</ul>'); inUl = false; }
		if (inOl) { out.push('</ol>'); inOl = false; }
	}
	function closeBlockquote() {
		if (inBlockquote) { out.push('</blockquote>'); inBlockquote = false; }
	}

	for (const raw of lines) {
		const line = raw;

		// Headings
		const hm = line.match(/^(#{1,6})\s+(.+)/);
		if (hm) {
			closeList(); closeBlockquote();
			const level = hm[1].length;
			out.push(`<h${level}>${inlineFormat(hm[2])}</h${level}>`);
			continue;
		}

		// Blockquote
		if (line.startsWith('> ')) {
			closeList();
			if (!inBlockquote) { out.push('<blockquote>'); inBlockquote = true; }
			out.push(`<p>${inlineFormat(line.slice(2))}</p>`);
			continue;
		} else if (inBlockquote && line.trim() === '') {
			closeBlockquote();
		}

		// HR
		if (/^[-*_]{3,}$/.test(line.trim())) {
			closeList(); closeBlockquote();
			out.push('<hr>');
			continue;
		}

		// Unordered list
		const ulm = line.match(/^(\s*)[-*+]\s+(.+)/);
		if (ulm) {
			closeBlockquote();
			if (!inUl) { out.push('<ul>'); inUl = true; }
			out.push(`<li>${inlineFormat(ulm[2])}</li>`);
			continue;
		}

		// Ordered list
		const olm = line.match(/^\d+\.\s+(.+)/);
		if (olm) {
			closeBlockquote();
			if (!inOl) { out.push('<ol>'); inOl = true; }
			out.push(`<li>${inlineFormat(olm[1])}</li>`);
			continue;
		}

		// Blank line
		if (line.trim() === '') {
			closeList(); closeBlockquote();
			out.push('<br>');
			continue;
		}

		// Paragraph
		closeList(); closeBlockquote();
		out.push(`<p>${inlineFormat(line)}</p>`);
	}

	closeList(); closeBlockquote();
	let result = out.join('\n');

	// Restore placeholders
	result = result.replace(/\x00CB(\d+)\x00/g, (_, i) => codeBlocks[Number(i)]);
	result = result.replace(/\x00IC(\d+)\x00/g, (_, i) => inlineCodes[Number(i)]);
	return result;
}

function inlineFormat(s: string): string {
	return s
		.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
		.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
		.replace(/\*(.+?)\*/g, '<em>$1</em>')
		.replace(/__(.+?)__/g, '<strong>$1</strong>')
		.replace(/_(.+?)_/g, '<em>$1</em>')
		.replace(/~~(.+?)~~/g, '<del>$1</del>');
}

// ── Helpers ──────────────────────────────────────────────────────────────────

function timeAgo(ts: number): string {
	const sec = Math.floor((Date.now() - ts) / 1000);
	if (sec < 60) return 'just now';
	if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
	if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
	return `${Math.floor(sec / 86400)}d ago`;
}

function escHtml(s: string): string {
	return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

// ── Icons (inline SVG, 16×16, stroke-based) ──────────────────────────────────

const ICON_OBSIDIAN = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><polygon points="7,1 13,4 13,10 7,13 1,10 1,4" stroke="#a78bfa" stroke-width="1.5" fill="rgba(167,139,250,0.15)"/><polygon points="7,3 10,5 9,9 7,11 5,9 4,5" fill="#a78bfa" opacity="0.6"/></svg>`;
const ICON_LOGSEQ   = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="5.5" stroke="#60a5fa" stroke-width="1.5"/><circle cx="7" cy="7" r="2.5" fill="#60a5fa" opacity="0.7"/></svg>`;
const ICON_FOLDER   = `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h6a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>`;
const ICON_FILE     = `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 1h6l4 4v10H2V1zM10 1v4h4"/></svg>`;
const ICON_UPLOAD   = `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><polyline points="4 6 8 2 12 6"/><line x1="8" y1="2" x2="8" y2="11"/><path d="M2 14h12"/></svg>`;
const ICON_SEARCH   = `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="6.5" cy="6.5" r="4"/><line x1="10" y1="10" x2="14" y2="14"/></svg>`;
const ICON_CHEVRON  = `<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><polyline points="3 4.5 6 7.5 9 4.5"/></svg>`;
const ICON_SYNC     = `<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2 8a6 6 0 1 1 1.5 4"/><polyline points="2 13 2 8 7 8"/></svg>`;

// ── Web Component ─────────────────────────────────────────────────────────────

export class FolkNotesApp extends HTMLElement {
	// Observed attributes
	static get observedAttributes() { return ['space', 'module-id']; }

	// State
	private _space = '';
	private _moduleId = 'rnotes';
	private _vaults: VaultMeta[] = [];
	private _notes: VaultNoteMeta[] = [];
	private _selectedVaultId: string | null = null;
	private _selectedNotePath: string | null = null;
	private _noteContent = '';
	private _searchQuery = '';
	private _folderOpen: Record<string, boolean> = {};
	private _loading = false;
	private _uploadOpen = false;
	private _uploadStatus = '';
	private _shadow: ShadowRoot;

	constructor() {
		super();
		this._shadow = this.attachShadow({ mode: 'open' });
	}

	get space() { return this._space; }
	set space(v: string) { this._space = v; this._fetchVaults(); }

	attributeChangedCallback(name: string, _old: string | null, val: string | null) {
		if (name === 'space' && val) { this._space = val; this._fetchVaults(); }
		if (name === 'module-id' && val) { this._moduleId = val; }
	}

	connectedCallback() {
		this._space = this.getAttribute('space') ?? '';
		this._moduleId = this.getAttribute('module-id') ?? 'rnotes';
		this._render();
		if (this._space) this._fetchVaults();
	}

	// ── API base ─────────────────────────────────────────────────────────────

	private _getApiBase(): string {
		const path = window.location.pathname;
		const match = path.match(/^(\/[^/]+)?\/rnotes/);
		return match ? match[0] : '';
	}

	// ── Data fetching ─────────────────────────────────────────────────────────

	private async _fetchVaults() {
		if (!this._space) return;
		this._loading = true; this._render();
		try {
			const base = this._getApiBase();
			const res = await fetch(`${base}/api/vault/list`);
			if (res.ok) {
				const data = await res.json() as { vaults: VaultMeta[] };
				this._vaults = data.vaults ?? [];
			}
		} catch { /* ignore */ }
		this._loading = false; this._render();
	}

	private async _selectVault(id: string) {
		this._selectedVaultId = id;
		this._selectedNotePath = null;
		this._noteContent = '';
		this._notes = [];
		this._render();
		try {
			const base = this._getApiBase();
			const res = await fetch(`${base}/api/vault/${id}/notes`);
			if (res.ok) {
				const data = await res.json() as { notes: VaultNoteMeta[] };
				this._notes = data.notes ?? [];
			}
		} catch { /* ignore */ }
		this._render();
	}

	private async _selectNote(path: string) {
		if (!this._selectedVaultId) return;
		this._selectedNotePath = path;
		this._noteContent = '';
		this._render();
		try {
			const base = this._getApiBase();
			const res = await fetch(
				`${base}/api/vault/${this._selectedVaultId}/note/${encodeURIComponent(path)}`
			);
			if (res.ok) {
				this._noteContent = await res.text();
			}
		} catch { /* ignore */ }
		this._render();
	}

	private async _uploadVault(form: HTMLFormElement) {
		const fd = new FormData(form);
		this._uploadStatus = 'Uploading…';
		this._render();
		try {
			const base = this._getApiBase();
			const token = localStorage.getItem('encryptid-token');
			const res = await fetch(`${base}/api/vault/upload`, {
				method: 'POST',
				body: fd,
				headers: token ? { 'Authorization': `Bearer ${token}` } : {},
			});
			const data = await res.json();
			if (res.ok) {
				this._uploadStatus = `Vault uploaded: ${data.name ?? 'done'}`;
				this._uploadOpen = false;
				await this._fetchVaults();
			} else {
				this._uploadStatus = `Error: ${data.error ?? 'upload failed'}`;
			}
		} catch (e: any) {
			this._uploadStatus = `Error: ${e.message}`;
		}
		this._render();
	}

	// ── Rendering ─────────────────────────────────────────────────────────────

	private _render() {
		this._shadow.innerHTML = `<style>${this._css()}</style>${this._html()}`;
		this._attachEvents();
	}

	private _css(): string {
		return `
:host { display: flex; flex-direction: column; height: 100%; font-family: system-ui, sans-serif; background: #0a0a0a; color: #e0e0e0; font-size: 13px; }
* { box-sizing: border-box; margin: 0; padding: 0; }

/* Toolbar */
.toolbar { display: flex; align-items: center; gap: 10px; padding: 8px 14px; background: #111; border-bottom: 1px solid #222; flex-shrink: 0; }
.toolbar-title { font-size: 12px; color: #888; margin-right: auto; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: 6px; border: 1px solid #333; background: #1a1a1a; color: #e0e0e0; cursor: pointer; font-size: 12px; white-space: nowrap; transition: background 0.15s; }
.btn:hover { background: #252525; border-color: #444; }
.btn.primary { background: #14b8a6; border-color: #0d9488; color: #fff; }
.btn.primary:hover { background: #0d9488; }
.search-input { background: #1a1a1a; border: 1px solid #333; border-radius: 6px; color: #e0e0e0; font-size: 12px; padding: 5px 10px; outline: none; width: 200px; }
.search-input:focus { border-color: #14b8a6; }

/* Layout */
.layout { display: flex; flex: 1; overflow: hidden; }

/* Left sidebar */
.sidebar { width: 250px; flex-shrink: 0; background: #111; border-right: 1px solid #222; display: flex; flex-direction: column; overflow: hidden; }
.sidebar-header { padding: 10px 14px; font-size: 11px; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 1px solid #1e1e1e; flex-shrink: 0; }
.vault-list { flex: 1; overflow-y: auto; padding: 6px 0; }
.vault-item { padding: 8px 14px; cursor: pointer; border-left: 3px solid transparent; transition: background 0.1s; }
.vault-item:hover { background: #1a1a1a; }
.vault-item.active { background: rgba(20,184,166,0.08); border-left-color: #14b8a6; }
.vault-name { font-size: 13px; color: #e0e0e0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vault-meta { display: flex; align-items: center; gap: 6px; margin-top: 3px; font-size: 11px; color: #666; }
.source-badge { display: inline-flex; align-items: center; gap: 3px; }
.sync-dot { width: 6px; height: 6px; border-radius: 50%; background: #14b8a6; flex-shrink: 0; }
.empty-state { padding: 20px 14px; color: #555; font-size: 12px; line-height: 1.5; }

/* Center file tree */
.file-tree { width: 400px; flex-shrink: 0; background: #0d0d0d; border-right: 1px solid #222; display: flex; flex-direction: column; overflow: hidden; }
.tree-search { padding: 8px 10px; border-bottom: 1px solid #1e1e1e; flex-shrink: 0; }
.tree-search .search-input { width: 100%; }
.tree-body { flex: 1; overflow-y: auto; padding: 4px 0; }
.folder-header { display: flex; align-items: center; gap: 6px; padding: 5px 10px; cursor: pointer; color: #888; font-size: 12px; font-weight: 500; user-select: none; }
.folder-header:hover { color: #ccc; }
.folder-header .chevron { transition: transform 0.15s; }
.folder-header .chevron.open { transform: rotate(0deg); }
.folder-header .chevron.closed { transform: rotate(-90deg); }
.folder-files { padding-left: 8px; }
.note-item { display: flex; flex-direction: column; padding: 6px 10px; cursor: pointer; border-left: 3px solid transparent; transition: background 0.1s; }
.note-item:hover { background: #1a1a1a; }
.note-item.active { background: rgba(20,184,166,0.08); border-left-color: #14b8a6; }
.note-title { font-size: 12px; color: #ddd; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.note-footer { display: flex; align-items: center; gap: 4px; margin-top: 3px; flex-wrap: wrap; }
.tag { display: inline-block; padding: 1px 5px; border-radius: 3px; background: rgba(20,184,166,0.12); color: #14b8a6; font-size: 10px; border: 1px solid rgba(20,184,166,0.2); }
.note-time { font-size: 10px; color: #555; margin-left: auto; white-space: nowrap; }
.no-vault { padding: 30px 16px; color: #555; font-size: 12px; text-align: center; line-height: 1.6; }

/* Preview panel */
.preview { flex: 1; display: flex; flex-direction: column; overflow: hidden; background: #0a0a0a; }
.preview-header { padding: 10px 16px; background: #111; border-bottom: 1px solid #222; flex-shrink: 0; }
.preview-path { font-size: 11px; color: #555; margin-bottom: 4px; word-break: break-all; }
.preview-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.preview-body { flex: 1; overflow-y: auto; padding: 24px 28px; line-height: 1.7; }
.preview-body h1, .preview-body h2, .preview-body h3, .preview-body h4, .preview-body h5, .preview-body h6 { color: #f0f0f0; margin: 1.2em 0 0.4em; font-weight: 600; line-height: 1.3; }
.preview-body h1 { font-size: 1.6em; border-bottom: 1px solid #222; padding-bottom: 0.3em; }
.preview-body h2 { font-size: 1.3em; }
.preview-body h3 { font-size: 1.1em; color: #ddd; }
.preview-body p { color: #c8c8c8; margin: 0.5em 0; }
.preview-body a { color: #14b8a6; text-decoration: none; }
.preview-body a:hover { text-decoration: underline; }
.preview-body a.wikilink { color: #60a5fa; border-bottom: 1px dashed rgba(96,165,250,0.5); }
.preview-body code { background: #1a1a1a; border: 1px solid #333; border-radius: 3px; padding: 1px 4px; font-family: 'Fira Code', monospace; font-size: 12px; color: #c8f; }
.preview-body pre { background: #131313; border: 1px solid #2a2a2a; border-radius: 6px; padding: 14px 16px; overflow-x: auto; margin: 1em 0; }
.preview-body pre code { background: none; border: none; padding: 0; color: #e0e0e0; font-size: 12px; }
.preview-body blockquote { border-left: 3px solid #333; padding-left: 14px; margin: 0.8em 0; color: #888; font-style: italic; }
.preview-body ul, .preview-body ol { padding-left: 1.5em; margin: 0.5em 0; color: #c0c0c0; }
.preview-body li { margin: 0.2em 0; }
.preview-body hr { border: none; border-top: 1px solid #2a2a2a; margin: 1.5em 0; }
.preview-body img { max-width: 100%; border-radius: 4px; }
.preview-body strong { color: #f0f0f0; }
.preview-body em { color: #c8a0f0; }
.preview-body del { color: #666; }
.no-preview { padding: 40px; color: #444; font-size: 13px; text-align: center; line-height: 1.8; }

/* Upload dialog */
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 100; backdrop-filter: blur(2px); }
.dialog { background: #1a1a1a; border: 1px solid #333; border-radius: 10px; padding: 24px; width: 380px; max-width: 90vw; }
.dialog-title { font-size: 15px; font-weight: 600; color: #f0f0f0; margin-bottom: 18px; }
.field { margin-bottom: 14px; }
.field label { display: block; font-size: 11px; color: #888; margin-bottom: 5px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.04em; }
.field input, .field select { width: 100%; background: #111; border: 1px solid #333; border-radius: 6px; color: #e0e0e0; font-size: 13px; padding: 7px 10px; outline: none; }
.field input:focus, .field select:focus { border-color: #14b8a6; }
.field select option { background: #1a1a1a; }
.dialog-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
.status-msg { margin-top: 10px; font-size: 12px; color: #14b8a6; min-height: 18px; }

/* Scrollbar */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #444; }

/* Loading spinner */
.spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid #333; border-top-color: #14b8a6; border-radius: 50%; animation: spin 0.7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
		`;
	}

	private _html(): string {
		const vault = this._vaults.find(v => v.id === this._selectedVaultId);
		return `
<div class="toolbar">
	<span class="toolbar-title">${ICON_SEARCH} ${escHtml(this._space || 'rNotes')}</span>
	<button class="btn primary" id="btn-upload">${ICON_UPLOAD} Upload Vault</button>
</div>
<div class="layout">
	${this._renderSidebar()}
	${this._renderFileTree()}
	${this._renderPreview(vault)}
</div>
${this._uploadOpen ? this._renderUploadDialog() : ''}
		`;
	}

	private _renderSidebar(): string {
		if (this._loading) {
			return `<div class="sidebar"><div class="sidebar-header">Vaults</div><div class="empty-state"><div class="spinner"></div></div></div>`;
		}
		const items = this._vaults.map(v => {
			const active = v.id === this._selectedVaultId ? ' active' : '';
			const icon = v.source === 'logseq' ? ICON_LOGSEQ : ICON_OBSIDIAN;
			const label = v.source === 'logseq' ? 'Logseq' : 'Obsidian';
			const ago = timeAgo(v.lastSyncedAt);
			return `
<div class="vault-item${active}" data-vault-id="${escHtml(v.id)}">
	<div class="vault-name">${escHtml(v.name)}</div>
	<div class="vault-meta">
		<span class="source-badge">${icon} ${label}</span>
		<span class="sync-dot" title="Synced"></span>
		${escHtml(String(v.totalNotes))} notes
		· ${ago}
	</div>
</div>`;
		}).join('');

		return `
<div class="sidebar">
	<div class="sidebar-header">${ICON_SYNC} Vaults</div>
	<div class="vault-list">
		${items || '<div class="empty-state">No vaults yet.<br>Upload a vault ZIP to get started.</div>'}
	</div>
</div>`;
	}

	private _renderFileTree(): string {
		if (!this._selectedVaultId) {
			return `<div class="file-tree"><div class="no-vault">Select a vault<br>to browse notes</div></div>`;
		}

		// Filter by search
		const q = this._searchQuery.toLowerCase();
		const filtered = q
			? this._notes.filter(n => n.title.toLowerCase().includes(q) || n.path.toLowerCase().includes(q))
			: this._notes;

		// Group by folder
		const folders: Record<string, VaultNoteMeta[]> = {};
		for (const n of filtered) {
			const parts = n.path.split('/');
			const folder = parts.length > 1 ? parts.slice(0, -1).join('/') : '(root)';
			(folders[folder] ??= []).push(n);
		}

		const folderHtml = Object.entries(folders).sort(([a], [b]) => a.localeCompare(b)).map(([folder, notes]) => {
			const isOpen = this._folderOpen[folder] !== false; // default open
			const chevronClass = isOpen ? 'open' : 'closed';
			const notesHtml = isOpen ? notes.sort((a, b) => a.title.localeCompare(b.title)).map(n => {
				const active = n.path === this._selectedNotePath ? ' active' : '';
				const tags = (n.tags ?? []).slice(0, 3).map(t => `<span class="tag">${escHtml(t)}</span>`).join('');
				const ago = timeAgo(n.lastModifiedAt);
				return `
<div class="note-item${active}" data-note-path="${escHtml(n.path)}">
	<div class="note-title">${ICON_FILE} ${escHtml(n.title || n.path.split('/').pop() || n.path)}</div>
	<div class="note-footer">${tags}<span class="note-time">${ago}</span></div>
</div>`;
			}).join('') : '';

			return `
<div class="folder-header" data-folder="${escHtml(folder)}">
	<span class="chevron ${chevronClass}">${ICON_CHEVRON}</span>
	${ICON_FOLDER} ${escHtml(folder)} <span style="color:#555;font-size:11px;margin-left:auto">${notes.length}</span>
</div>
<div class="folder-files">${notesHtml}</div>`;
		}).join('');

		return `
<div class="file-tree">
	<div class="tree-search">
		<input class="search-input" type="search" id="tree-search" placeholder="Search notes…" value="${escHtml(this._searchQuery)}">
	</div>
	<div class="tree-body">
		${folderHtml || '<div class="no-vault">No notes found</div>'}
	</div>
</div>`;
	}

	private _renderPreview(vault?: VaultMeta): string {
		if (!this._selectedNotePath) {
			return `<div class="preview"><div class="no-preview">Select a note<br>to preview it here</div></div>`;
		}
		const note = this._notes.find(n => n.path === this._selectedNotePath);
		const tags = (note?.tags ?? []).map(t => `<span class="tag">${escHtml(t)}</span>`).join('');
		const modTime = note ? `Last modified ${timeAgo(note.lastModifiedAt)}` : '';

		let bodyHtml: string;
		if (!this._noteContent) {
			bodyHtml = `<div style="display:flex;justify-content:center;padding:40px"><div class="spinner"></div></div>`;
		} else {
			bodyHtml = renderMarkdown(this._noteContent);
		}

		return `
<div class="preview">
	<div class="preview-header">
		<div class="preview-path">${escHtml(this._selectedNotePath)} ${modTime ? `· ${modTime}` : ''}</div>
		${tags ? `<div class="preview-tags">${tags}</div>` : ''}
	</div>
	<div class="preview-body">${bodyHtml}</div>
</div>`;
	}

	private _renderUploadDialog(): string {
		return `
<div class="overlay" id="overlay">
	<div class="dialog">
		<div class="dialog-title">${ICON_UPLOAD} Upload Vault</div>
		<form id="upload-form">
			<div class="field">
				<label>Vault Name</label>
				<input type="text" name="name" placeholder="My Obsidian Vault" required>
			</div>
			<div class="field">
				<label>Source</label>
				<select name="source">
					<option value="obsidian">Obsidian</option>
					<option value="logseq">Logseq</option>
				</select>
			</div>
			<div class="field">
				<label>Vault ZIP File</label>
				<input type="file" name="file" accept=".zip" required>
			</div>
			<div class="status-msg" id="upload-status">${escHtml(this._uploadStatus)}</div>
			<div class="dialog-actions">
				<button type="button" class="btn" id="btn-cancel-upload">Cancel</button>
				<button type="submit" class="btn primary">${ICON_UPLOAD} Upload</button>
			</div>
		</form>
	</div>
</div>`;
	}

	// ── Event wiring ──────────────────────────────────────────────────────────

	private _attachEvents() {
		const $ = (id: string) => this._shadow.getElementById(id);

		// Upload button
		$('btn-upload')?.addEventListener('click', () => {
			this._uploadOpen = true; this._uploadStatus = ''; this._render();
		});

		// Close overlay
		$('btn-cancel-upload')?.addEventListener('click', () => {
			this._uploadOpen = false; this._render();
		});
		$('overlay')?.addEventListener('click', (e) => {
			if ((e.target as HTMLElement).id === 'overlay') { this._uploadOpen = false; this._render(); }
		});

		// Upload form submit
		const uploadForm = $('upload-form') as HTMLFormElement | null;
		uploadForm?.addEventListener('submit', async (e) => {
			e.preventDefault();
			await this._uploadVault(uploadForm);
		});

		// Vault selection
		this._shadow.querySelectorAll<HTMLElement>('.vault-item').forEach(el => {
			el.addEventListener('click', () => {
				const id = el.dataset.vaultId;
				if (id) this._selectVault(id);
			});
		});

		// Folder toggle
		this._shadow.querySelectorAll<HTMLElement>('.folder-header').forEach(el => {
			el.addEventListener('click', () => {
				const f = el.dataset.folder;
				if (f !== undefined) {
					this._folderOpen[f] = !(this._folderOpen[f] !== false);
					this._render();
				}
			});
		});

		// Note selection
		this._shadow.querySelectorAll<HTMLElement>('.note-item').forEach(el => {
			el.addEventListener('click', () => {
				const path = el.dataset.notePath;
				if (path) this._selectNote(path);
			});
		});

		// Tree search
		const treeSearch = $('tree-search') as HTMLInputElement | null;
		treeSearch?.addEventListener('input', (e) => {
			this._searchQuery = (e.target as HTMLInputElement).value;
			this._render();
		});

		// Wikilinks in preview
		this._shadow.querySelectorAll<HTMLAnchorElement>('a.wikilink').forEach(a => {
			a.addEventListener('click', (e) => {
				e.preventDefault();
				const target = a.dataset.target ?? '';
				const match = this._notes.find(n =>
					n.title.toLowerCase() === target.toLowerCase() ||
					n.path.toLowerCase().includes(target.toLowerCase())
				);
				if (match) this._selectNote(match.path);
			});
		});
	}
}

// ── Register custom element ───────────────────────────────────────────────────

if (!customElements.get('folk-notes-app')) {
	customElements.define('folk-notes-app', FolkNotesApp);
}
