<script lang="ts">
	import { base } from '$app/paths';
	import { goto } from '$app/navigation';
	import { page } from '$app/state';
	import {
		api,
		ApiError,
		type AdminDto,
		type Group,
		type GroupDetail,
		type GroupMember,
		type AppRole,
		type Script,
		type GroupVarItem,
		type GroupSecretItem,
		type GroupCollection,
		type GroupCollectionKind,
		type GroupTriggerTemplate,
		type GroupRouteTemplate,
		type SuppressionInfo,
		type ExtensionPointInfo,
		type GroupDeadLetter,
		type ProjectListItem,
		type FileMeta,
		type GroupDocEntry
	} from '$lib/api';
	import ConfirmModal from '$lib/ConfirmModal.svelte';
	import ActionMenu from '$lib/ActionMenu.svelte';
	import RoleChip from '$lib/RoleChip.svelte';
	import GroupTabBar from '$lib/GroupTabBar.svelte';
	import { currentUser } from '$lib/auth';
	import { canCreateApp } from '$lib/capabilities';

	const me = $derived($currentUser);
	// Mirror app-admin gating: instance owner/admin manage groups, their
	// members, structure, and deletion. The backend is the ground truth.
	const canManage = $derived(canCreateApp(me));

	type Tab =
		| 'overview'
		| 'members'
		| 'vars'
		| 'secrets'
		| 'collections'
		| 'scripts'
		| 'triggers'
		| 'routes'
		| 'suppressions'
		| 'extension-points'
		| 'dead-letters'
		| 'ownership'
		| 'settings';
	const validTabs: Tab[] = [
		'overview',
		'members',
		'vars',
		'secrets',
		'collections',
		'scripts',
		'triggers',
		'routes',
		'suppressions',
		'extension-points',
		'dead-letters',
		'ownership',
		'settings'
	];
	const isValidTab = (s: string | null): s is Tab => validTabs.includes(s as Tab);
	let activeTab = $derived.by<Tab>(() => {
		const qp = page.url.searchParams.get('tab');
		return isValidTab(qp) ? qp : 'overview';
	});

	let slug = $derived(page.params.slug ?? '');
	let group = $state<GroupDetail | null>(null);
	let loadError = $state<string | null>(null);
	let loading = $state(true);

	// All groups — used for the reparent + rename forms and to offer a
	// parent picker that excludes the node itself.
	let allGroups = $state<Group[]>([]);

	// Settings (rename — name/description only; slug is frozen).
	let editName = $state('');
	let editDescription = $state('');
	let savingSettings = $state(false);
	let settingsError = $state<string | null>(null);

	// Reparent.
	let reparentTarget = $state('');
	let reparenting = $state(false);
	let reparentError = $state<string | null>(null);

	// Delete.
	let confirmingDelete = $state(false);
	let deleting = $state(false);
	let deleteError = $state<string | null>(null);

	// Members.
	let members = $state<GroupMember[]>([]);
	let eligibleUsers = $state<AdminDto[]>([]);
	let eligibleLoadError = $state<string | null>(null);
	let addMemberUserId = $state('');
	let addMemberRole = $state<AppRole>('viewer');
	let addingMember = $state(false);
	let addMemberError = $state<string | null>(null);
	let memberToRemove = $state<GroupMember | null>(null);
	let removingMember = $state(false);
	let removeMemberError = $state<string | null>(null);
	let roleChangeBusy = $state<string | null>(null);
	let memberActionError = $state<string | null>(null);

	async function loadGroup() {
		loading = true;
		loadError = null;
		try {
			const fetched = await api.groups.get(slug);
			group = fetched;
			editName = fetched.name;
			editDescription = fetched.description ?? '';
			reparentTarget = fetched.parent_id ?? '';
			const loaders: Promise<unknown>[] = [loadAllGroups()];
			if (canManage) {
				loaders.push(loadMembers(slug), loadEligibleUsers());
			}
			await Promise.all(loaders);
		} catch (e) {
			loadError = e instanceof Error ? e.message : String(e);
		} finally {
			loading = false;
		}
	}

	async function loadAllGroups() {
		try {
			allGroups = await api.groups.list();
		} catch {
			allGroups = [];
		}
	}

	async function loadMembers(idOrSlug: string) {
		try {
			members = await api.groups.members.list(idOrSlug);
		} catch (e) {
			members = [];
			memberActionError = e instanceof Error ? e.message : String(e);
		}
	}

	async function loadEligibleUsers() {
		eligibleLoadError = null;
		try {
			const all = await api.admins.list();
			eligibleUsers = all.filter((u) => u.is_active && u.instance_role === 'member');
		} catch (e) {
			eligibleUsers = [];
			eligibleLoadError =
				e instanceof ApiError && e.status === 403
					? 'Only instance owners/admins can browse the user directory to invite new members.'
					: e instanceof Error
						? e.message
						: String(e);
		}
	}

	const eligibleAfterFilter = $derived(
		eligibleUsers.filter((u) => !members.some((m) => m.user_id === u.id))
	);

	// Reparent picker options: every group except this node (a group
	// can't parent itself; the backend also rejects descendant cycles
	// with a 409, surfaced below).
	const reparentOptions = $derived(allGroups.filter((g) => g.id !== group?.id));

	// -----------------------------------------------------------------------
	// Read-only inspection tabs. Each keeps its own state + loaded flag so it
	// fetches lazily the first time the tab activates (mirrors the app page).
	// All are READ-ONLY — shared writes + template authoring happen via `pic`.
	// -----------------------------------------------------------------------

	function errText(e: unknown): string {
		return e instanceof ApiError ? e.message : e instanceof Error ? e.message : String(e);
	}

	// Structured load error for the read-only tabs: a plain group-viewer's 403
	// on a capability-gated read renders as a calm muted note, not a red panel.
	type LoadError = { forbidden: true } | { message: string } | null;
	function classifyError(e: unknown): LoadError {
		if (e instanceof ApiError && e.status === 403) return { forbidden: true };
		return { message: errText(e) };
	}

	// Vars.
	let varsLoaded = $state(false);
	let vars = $state<GroupVarItem[]>([]);
	let varsError = $state<LoadError>(null);
	async function loadVars() {
		if (!group) return;
		varsError = null;
		try {
			const r = await api.groups.vars(group.id);
			vars = r.vars;
			varsLoaded = true;
		} catch (e) {
			varsError = classifyError(e);
		}
	}

	// Secrets — NAMES + env + updated_at only, never values. The list requires
	// GroupSecretsWrite, so a plain viewer gets a 403 shown as a muted note.
	let secretsLoaded = $state(false);
	let secrets = $state<GroupSecretItem[]>([]);
	let secretsError = $state<LoadError>(null);
	async function loadSecrets() {
		if (!group) return;
		secretsError = null;
		try {
			const r = await api.groups.secrets(group.id);
			secrets = r.secrets;
			secretsLoaded = true;
		} catch (e) {
			secretsError = classifyError(e);
		}
	}

	// Collections + KV/Docs/Files browsers.
	let collectionsLoaded = $state(false);
	let collections = $state<GroupCollection[]>([]);
	let collectionsError = $state<LoadError>(null);
	async function loadCollections() {
		if (!group) return;
		collectionsError = null;
		try {
			collections = await api.groups.collections(group.id);
			collectionsLoaded = true;
		} catch (e) {
			collectionsError = classifyError(e);
		}
	}

	// Shared-collection data browser (one at a time). `browseKind` selects the
	// endpoint family; only kv/docs/files have a browser.
	let browseKind = $state<GroupCollectionKind>('kv');
	let browseCollection = $state('');
	let browseActive = $state('');
	let browseActiveKind = $state<GroupCollectionKind>('kv');
	let browseKeys = $state<string[]>([]);
	let browseDocs = $state<GroupDocEntry[]>([]);
	let browseFiles = $state<FileMeta[]>([]);
	let browseCursor = $state<string | null>(null);
	let browseLoading = $state(false);
	let browseError = $state<LoadError>(null);
	// A single fetched KV value / doc, shown inline on demand.
	let kvValue = $state<{ key: string; value: unknown } | null>(null);

	async function browse(cursor?: string) {
		if (!group) return;
		const c = browseCollection.trim();
		if (!c) {
			browseError = { message: 'Pick or enter a collection name to browse.' };
			return;
		}
		browseLoading = true;
		browseError = null;
		try {
			if (browseKind === 'kv') {
				const r = await api.groups.groupKv.list(group.id, c, { cursor, limit: 100 });
				browseKeys = cursor ? [...browseKeys, ...r.keys] : r.keys;
				browseCursor = r.next_cursor;
			} else if (browseKind === 'docs') {
				const r = await api.groups.groupDocs.list(group.id, c, { cursor, limit: 100 });
				browseDocs = cursor ? [...browseDocs, ...r.docs] : r.docs;
				browseCursor = r.next_cursor;
			} else {
				const r = await api.groups.groupFiles.list(group.id, c, { cursor, limit: 100 });
				browseFiles = cursor ? [...browseFiles, ...r.files] : r.files;
				browseCursor = r.next_cursor;
			}
			if (!cursor) {
				browseActive = c;
				browseActiveKind = browseKind;
				kvValue = null;
			}
		} catch (e) {
			browseError = classifyError(e);
		} finally {
			browseLoading = false;
		}
	}

	async function loadKvValue(key: string) {
		if (!group) return;
		try {
			const r = await api.groups.groupKv.get(group.id, browseActive, key);
			kvValue = { key, value: r.value };
		} catch (e) {
			browseError = classifyError(e);
		}
	}

	function pickCollection(c: GroupCollection) {
		if (c.kind === 'topic' || c.kind === 'queue') return;
		browseKind = c.kind;
		browseCollection = c.name;
		void browse();
	}

	// Scripts.
	let scriptsLoaded = $state(false);
	let scripts = $state<Script[]>([]);
	let scriptsError = $state<LoadError>(null);
	async function loadScripts() {
		if (!group) return;
		scriptsError = null;
		try {
			scripts = await api.groups.scripts(group.id);
			scriptsLoaded = true;
		} catch (e) {
			scriptsError = classifyError(e);
		}
	}

	// Trigger templates.
	let triggersLoaded = $state(false);
	let triggers = $state<GroupTriggerTemplate[]>([]);
	let triggersError = $state<LoadError>(null);
	async function loadTriggers() {
		if (!group) return;
		triggersError = null;
		try {
			triggers = await api.groups.triggers(group.id);
			triggersLoaded = true;
		} catch (e) {
			triggersError = classifyError(e);
		}
	}

	// Route templates.
	let routesLoaded = $state(false);
	let routes = $state<GroupRouteTemplate[]>([]);
	let routesError = $state<LoadError>(null);
	async function loadRoutes() {
		if (!group) return;
		routesError = null;
		try {
			routes = await api.groups.routes(group.id);
			routesLoaded = true;
		} catch (e) {
			routesError = classifyError(e);
		}
	}

	// Suppressions.
	let suppressionsLoaded = $state(false);
	let suppressions = $state<SuppressionInfo[]>([]);
	let suppressionsError = $state<LoadError>(null);
	async function loadSuppressions() {
		if (!group) return;
		suppressionsError = null;
		try {
			suppressions = await api.groups.suppressions(group.id);
			suppressionsLoaded = true;
		} catch (e) {
			suppressionsError = classifyError(e);
		}
	}

	// Extension points.
	let extPointsLoaded = $state(false);
	let extPoints = $state<ExtensionPointInfo[]>([]);
	let extPointsError = $state<LoadError>(null);
	async function loadExtPoints() {
		if (!group) return;
		extPointsError = null;
		try {
			extPoints = await api.groups.extensionPoints(group.id);
			extPointsLoaded = true;
		} catch (e) {
			extPointsError = classifyError(e);
		}
	}

	// Dead-letters (shared-queue).
	let deadLettersLoaded = $state(false);
	let deadLetters = $state<GroupDeadLetter[]>([]);
	let deadLettersError = $state<LoadError>(null);
	let dlUnresolvedOnly = $state(false);
	async function loadDeadLetters() {
		if (!group) return;
		deadLettersError = null;
		try {
			deadLetters = await api.groups.deadLetters(group.id, {
				unresolved: dlUnresolvedOnly,
				limit: 100
			});
			deadLettersLoaded = true;
		} catch (e) {
			deadLettersError = classifyError(e);
		}
	}

	// Ownership — the global projects list. Per-group approval policy has no
	// endpoint; it is previewed via `pic plan`.
	let projectsLoaded = $state(false);
	let projects = $state<ProjectListItem[]>([]);
	let projectsError = $state<LoadError>(null);
	async function loadProjects() {
		projectsError = null;
		try {
			projects = await api.projects.list();
			projectsLoaded = true;
		} catch (e) {
			projectsError = classifyError(e);
		}
	}

	// Lazy per-tab loading: fetch the active tab's data the first time it is
	// shown. `group` must be loaded first.
	$effect(() => {
		if (!group) return;
		switch (activeTab) {
			case 'vars':
				if (!varsLoaded) void loadVars();
				break;
			case 'secrets':
				if (canManage && !secretsLoaded) void loadSecrets();
				break;
			case 'collections':
				if (!collectionsLoaded) void loadCollections();
				break;
			case 'scripts':
				if (!scriptsLoaded) void loadScripts();
				break;
			case 'triggers':
				if (!triggersLoaded) void loadTriggers();
				break;
			case 'routes':
				if (!routesLoaded) void loadRoutes();
				break;
			case 'suppressions':
				if (!suppressionsLoaded) void loadSuppressions();
				break;
			case 'extension-points':
				if (!extPointsLoaded) void loadExtPoints();
				break;
			case 'dead-letters':
				if (!deadLettersLoaded) void loadDeadLetters();
				break;
			case 'ownership':
				if (!projectsLoaded) void loadProjects();
				break;
		}
	});

	async function saveSettings(event: Event) {
		event.preventDefault();
		if (!group) return;
		savingSettings = true;
		settingsError = null;
		try {
			const updated = await api.groups.update(group.id, {
				name: editName.trim() !== group.name ? editName.trim() : undefined,
				description:
					editDescription !== (group.description ?? '')
						? editDescription || null
						: undefined
			});
			group = { ...group, name: updated.name, description: updated.description };
		} catch (e) {
			settingsError = e instanceof Error ? e.message : String(e);
		} finally {
			savingSettings = false;
		}
	}

	async function submitReparent(event: Event) {
		event.preventDefault();
		if (!group) return;
		reparenting = true;
		reparentError = null;
		try {
			await api.groups.reparent(group.id, reparentTarget.trim() || null);
			await loadGroup();
		} catch (e) {
			reparentError =
				e instanceof ApiError && e.status === 409
					? e.message
					: e instanceof Error
						? e.message
						: String(e);
		} finally {
			reparenting = false;
		}
	}

	function askDelete() {
		deleteError = null;
		confirmingDelete = true;
	}

	async function confirmDelete() {
		if (!group) return;
		deleting = true;
		deleteError = null;
		try {
			await api.groups.delete(group.id);
			await goto(`${base}/groups`);
		} catch (e) {
			// 409 RESTRICT: the group still has subgroups or apps. Surface
			// the server's message cleanly rather than a bare status code.
			deleteError = e instanceof Error ? e.message : String(e);
		} finally {
			deleting = false;
		}
	}

	async function submitAddMember(event: Event) {
		event.preventDefault();
		if (!group || !addMemberUserId) return;
		addingMember = true;
		addMemberError = null;
		try {
			await api.groups.members.grant(group.id, {
				user_id: addMemberUserId,
				role: addMemberRole
			});
			addMemberUserId = '';
			addMemberRole = 'viewer';
			await loadMembers(group.id);
		} catch (e) {
			addMemberError = e instanceof Error ? e.message : String(e);
		} finally {
			addingMember = false;
		}
	}

	async function changeMemberRole(member: GroupMember, role: AppRole) {
		if (!group || member.role === role) return;
		roleChangeBusy = member.user_id;
		memberActionError = null;
		try {
			await api.groups.members.update(group.id, member.user_id, role);
			await loadMembers(group.id);
		} catch (e) {
			memberActionError = e instanceof Error ? e.message : String(e);
		} finally {
			roleChangeBusy = null;
		}
	}

	function askRemoveMember(member: GroupMember) {
		removeMemberError = null;
		memberToRemove = member;
	}

	async function confirmRemoveMember() {
		if (!group || !memberToRemove) return;
		removingMember = true;
		removeMemberError = null;
		try {
			await api.groups.members.remove(group.id, memberToRemove.user_id);
			memberToRemove = null;
			await loadMembers(group.id);
		} catch (e) {
			removeMemberError = e instanceof Error ? e.message : String(e);
		} finally {
			removingMember = false;
		}
	}

	function shortDate(iso: string): string {
		try {
			return new Date(iso).toLocaleDateString();
		} catch {
			return iso;
		}
	}

	function fmtTime(iso: string): string {
		try {
			return new Date(iso).toLocaleString();
		} catch {
			return iso;
		}
	}

	function fmtSize(bytes: number): string {
		if (bytes < 1024) return `${bytes} B`;
		if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
		return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
	}

	function preview(v: unknown, max = 120): string {
		let s: string;
		try {
			s = typeof v === 'string' ? v : JSON.stringify(v);
		} catch {
			s = String(v);
		}
		return s.length > max ? s.slice(0, max) + '…' : s;
	}

	// Collections that have a data browser (kv/docs/files).
	const browsableCollections = $derived(
		collections.filter((c) => c.kind === 'kv' || c.kind === 'docs' || c.kind === 'files')
	);

	$effect(() => {
		void slug;
		void loadGroup();
	});

	// A viewer following a stale link to a manage-only tab gets bounced
	// to the overview. The backend still 403s the underlying calls.
	$effect(() => {
		if (!canManage && (activeTab === 'settings' || activeTab === 'members')) {
			void goto(`${base}/groups/${slug}?tab=overview`, { replaceState: true });
		}
	});
</script>

<header class="page-head">
	<div class="breadcrumb">
		<a href="{base}/groups">Groups</a>
		{#if group}
			{#each group.path as p (p.id)}
				<span aria-hidden="true">/</span>
				{#if p.id === group.id}
					<code>{p.slug}</code>
				{:else}
					<a href="{base}/groups/{p.slug}">{p.name}</a>
				{/if}
			{/each}
		{:else}
			<span aria-hidden="true">/</span>
			<code>{slug}</code>
		{/if}
	</div>
	{#if group}
		<h1>{group.name}</h1>
		{#if group.description}<p class="muted">{group.description}</p>{/if}
	{/if}
</header>

{#snippet loadErrorNote(err: LoadError)}
	{#if err === null}
		<!-- no error -->
	{:else if 'forbidden' in err}
		<p class="muted">You don't have permission to view this.</p>
	{:else}
		<p class="error">{err.message}</p>
	{/if}
{/snippet}

{#if group}
	<GroupTabBar {slug} {canManage} currentTab={activeTab} />

	{#if activeTab === 'overview'}
		<section>
			<h2>Subgroups</h2>
			{#if group.subgroups.length === 0}
				<p class="muted">No subgroups.</p>
			{:else}
				<ul class="list">
					{#each group.subgroups as sub (sub.id)}
						<li>
							<a href="{base}/groups/{sub.slug}">
								<div class="primary">
									<strong>{sub.name}</strong>
									<span class="muted">/{sub.slug}</span>
								</div>
								<div class="secondary muted">{sub.description ?? '—'}</div>
							</a>
						</li>
					{/each}
				</ul>
			{/if}

			<h2>Apps</h2>
			{#if group.apps.length === 0}
				<p class="muted">No apps in this group.</p>
			{:else}
				<ul class="list">
					{#each group.apps as app (app.id)}
						<li>
							<a href="{base}/apps/{app.slug}">
								<div class="primary">
									<strong>{app.name}</strong>
									<span class="muted">/{app.slug}</span>
								</div>
								<div class="secondary muted">{app.description ?? '—'}</div>
							</a>
						</li>
					{/each}
				</ul>
			{/if}
		</section>
	{:else if activeTab === 'members' && canManage}
		<section>
			<h2>Members</h2>
			<p class="muted">
				Users with explicit access to this group. Instance owners and admins
				already have implicit access — they are not listed here. Use the Users
				page to invite a <code>member</code> first, then grant them group access
				below.
			</p>

			<form class="create-form" onsubmit={submitAddMember}>
				<div class="row">
					<label class="grow">
						<span>User</span>
						<select
							bind:value={addMemberUserId}
							disabled={!!eligibleLoadError || eligibleAfterFilter.length === 0}
							required
						>
							<option value="" disabled>Pick a member to invite…</option>
							{#each eligibleAfterFilter as u (u.id)}
								<option value={u.id}>{u.username}{u.email ? ` (${u.email})` : ''}</option>
							{/each}
						</select>
					</label>
					<label>
						<span>Role</span>
						<select bind:value={addMemberRole} disabled={!!eligibleLoadError}>
							<option value="viewer">viewer</option>
							<option value="editor">editor</option>
							<option value="app_admin">app admin</option>
						</select>
					</label>
				</div>
				{#if eligibleLoadError}
					<p class="muted">{eligibleLoadError}</p>
				{:else if eligibleAfterFilter.length === 0}
					<p class="muted">
						No eligible users to invite. Create a <code>member</code> on the Users
						page first.
					</p>
				{/if}
				{#if addMemberError}
					<div class="error">{addMemberError}</div>
				{/if}
				<div class="actions">
					<button
						type="submit"
						disabled={addingMember || !addMemberUserId || !!eligibleLoadError}
					>
						{addingMember ? 'Adding…' : 'Add member'}
					</button>
				</div>
			</form>

			{#if memberActionError}
				<div class="error">{memberActionError}</div>
			{/if}

			{#if members.length === 0}
				<p class="muted">No explicit members yet.</p>
			{:else}
				<div class="table">
					<div class="row head-row">
						<div>User</div>
						<div>Instance</div>
						<div>Group role</div>
						<div>Joined</div>
						<div class="actions-col"></div>
					</div>
					{#each members as m (m.user_id)}
						<div class="row member-row" class:inactive={!m.is_active}>
							<div>
								<strong>{m.username}</strong>
								{#if m.email}<span class="muted">{m.email}</span>{/if}
								{#if !m.is_active}<span class="muted">(inactive)</span>{/if}
							</div>
							<div><RoleChip role={m.instance_role} size="sm" /></div>
							<div><RoleChip appRole={m.role} size="sm" /></div>
							<div>{shortDate(m.created_at)}</div>
							<div class="actions-col">
								<ActionMenu
									label="Member actions for {m.username}"
									items={[
										{
											label: 'Make app admin',
											disabled: m.role === 'app_admin' || roleChangeBusy === m.user_id,
											onClick: () => changeMemberRole(m, 'app_admin')
										},
										{
											label: 'Make editor',
											disabled: m.role === 'editor' || roleChangeBusy === m.user_id,
											onClick: () => changeMemberRole(m, 'editor')
										},
										{
											label: 'Make viewer',
											disabled: m.role === 'viewer' || roleChangeBusy === m.user_id,
											onClick: () => changeMemberRole(m, 'viewer')
										},
										{
											label: 'Remove from group',
											danger: true,
											onClick: () => askRemoveMember(m)
										}
									]}
								/>
							</div>
						</div>
					{/each}
				</div>
			{/if}
		</section>
	{:else if activeTab === 'vars'}
		<section>
			<h2>Vars</h2>
			<p class="muted">
				The group's own config vars — inherited by every descendant app,
				env-scoped, nearest-owner-wins. Edit via
				<code>pic apply</code> or the vars API. A tombstone suppresses an inherited key.
			</p>
			{#if varsError}
				{@render loadErrorNote(varsError)}
			{:else if !varsLoaded}
				<p class="muted">Loading…</p>
			{:else if vars.length === 0}
				<p class="muted">No vars declared on this group.</p>
			{:else}
				<table class="grid">
					<thead>
						<tr><th>Key</th><th>Env</th><th>Value</th><th>Updated</th></tr>
					</thead>
					<tbody>
						{#each vars as v (v.key + '@' + v.env)}
							<tr>
								<td class="mono">{v.key}</td>
								<td><code>{v.env}</code></td>
								<td class="mono small">
									{#if v.is_tombstone}
										<span class="badge badge-muted" title="Suppresses an inherited key">
											tombstone
										</span>
									{:else}
										{preview(v.value)}
									{/if}
								</td>
								<td>{fmtTime(v.updated_at)}</td>
							</tr>
						{/each}
					</tbody>
				</table>
			{/if}
		</section>
	{:else if activeTab === 'secrets' && canManage}
		<section>
			<h2>Secrets</h2>
			<p class="muted">
				Secret <strong>names</strong> and their env scope only — values never leave
				the server. Set them with <code>pic apply</code> or the secrets API.
			</p>
			{#if secretsError}
				{@render loadErrorNote(secretsError)}
			{:else if !secretsLoaded}
				<p class="muted">Loading…</p>
			{:else if secrets.length === 0}
				<p class="muted">No secrets declared on this group.</p>
			{:else}
				<table class="grid">
					<thead>
						<tr><th>Name</th><th>Env</th><th>Updated</th></tr>
					</thead>
					<tbody>
						{#each secrets as sct (sct.name + '@' + sct.env)}
							<tr>
								<td class="mono">{sct.name}</td>
								<td><code>{sct.env}</code></td>
								<td>{fmtTime(sct.updated_at)}</td>
							</tr>
						{/each}
					</tbody>
				</table>
			{/if}
		</section>
	{:else if activeTab === 'collections'}
		<section>
			<h2>Shared collections</h2>
			<p class="muted">
				Cross-app shared stores declared on this group. Reads are open to any
				subtree script; writes require an editor+ via
				<code>kv::shared_collection(…)</code> / <code>docs</code> / <code>files</code>.
				Topic and queue collections are storeless / consumed via triggers, so
				they have no data browser here.
			</p>
			{#if collectionsError}
				{@render loadErrorNote(collectionsError)}
			{:else if !collectionsLoaded}
				<p class="muted">Loading…</p>
			{:else if collections.length === 0}
				<p class="muted">No shared collections declared on this group.</p>
			{:else}
				<table class="grid">
					<thead>
						<tr><th>Name</th><th>Kind</th><th></th></tr>
					</thead>
					<tbody>
						{#each collections as c (c.kind + '/' + c.name)}
							<tr>
								<td class="mono">{c.name}</td>
								<td><span class="badge">{c.kind}</span></td>
								<td>
									{#if c.kind === 'topic' || c.kind === 'queue'}
										<span class="muted small">no data browser</span>
									{:else}
										<button type="button" class="secondary" onclick={() => pickCollection(c)}>
											Browse
										</button>
									{/if}
								</td>
							</tr>
						{/each}
					</tbody>
				</table>
			{/if}

			<h3>Data browser</h3>
			<form
				class="collection-form"
				onsubmit={(e) => {
					e.preventDefault();
					void browse();
				}}
			>
				<label>
					<span>Kind</span>
					<select bind:value={browseKind}>
						<option value="kv">kv</option>
						<option value="docs">docs</option>
						<option value="files">files</option>
					</select>
				</label>
				<label>
					<span>Collection</span>
					<input bind:value={browseCollection} placeholder="catalog" required />
				</label>
				<button type="submit" disabled={browseLoading || !browseCollection.trim()}>
					{browseLoading ? 'Loading…' : 'Browse'}
				</button>
			</form>
			{#if browsableCollections.length > 0}
				<p class="hint">
					Declared:
					{#each browsableCollections as c, i (c.kind + '/' + c.name)}
						{#if i > 0}, {/if}
						<button
							class="hint-chip"
							type="button"
							onclick={() => {
								browseKind = c.kind as GroupCollectionKind;
								browseCollection = c.name;
							}}
						>
							{c.name} ({c.kind})
						</button>
					{/each}
				</p>
			{/if}

			{#if browseError}
				{@render loadErrorNote(browseError)}
			{/if}

			{#if browseActive}
				{#if browseActiveKind === 'kv'}
					{#if browseKeys.length === 0 && !browseLoading}
						<p class="muted">No keys in KV collection <code>{browseActive}</code>.</p>
					{:else}
						<table class="grid">
							<thead>
								<tr><th>Key</th><th></th></tr>
							</thead>
							<tbody>
								{#each browseKeys as k (k)}
									<tr>
										<td class="mono">{k}</td>
										<td>
											<button type="button" class="secondary" onclick={() => loadKvValue(k)}>
												View value
											</button>
										</td>
									</tr>
									{#if kvValue && kvValue.key === k}
										<tr>
											<td colspan="2"><pre class="value">{preview(kvValue.value, 4000)}</pre></td>
										</tr>
									{/if}
								{/each}
							</tbody>
						</table>
					{/if}
				{:else if browseActiveKind === 'docs'}
					{#if browseDocs.length === 0 && !browseLoading}
						<p class="muted">No documents in docs collection <code>{browseActive}</code>.</p>
					{:else}
						<table class="grid">
							<thead>
								<tr><th>ID</th><th>Data</th></tr>
							</thead>
							<tbody>
								{#each browseDocs as d (d.id)}
									<tr>
										<td class="mono small">{d.id}</td>
										<td class="mono small">{preview(d.data)}</td>
									</tr>
								{/each}
							</tbody>
						</table>
					{/if}
				{:else if browseActiveKind === 'files'}
					{#if browseFiles.length === 0 && !browseLoading}
						<p class="muted">No files in files collection <code>{browseActive}</code>.</p>
					{:else}
						<table class="grid">
							<thead>
								<tr><th>Name</th><th>Type</th><th>Size</th><th>Created</th><th></th></tr>
							</thead>
							<tbody>
								{#each browseFiles as f (f.id)}
									<tr>
										<td>{f.name}</td>
										<td><code>{f.content_type}</code></td>
										<td>{fmtSize(f.size)}</td>
										<td>{fmtTime(f.created_at)}</td>
										<td>
											<a
												class="secondary"
												href={api.groups.groupFiles.downloadUrl(group.id, browseActive, f.id)}
												download={f.name}
											>
												Download
											</a>
										</td>
									</tr>
								{/each}
							</tbody>
						</table>
					{/if}
				{/if}
				{#if browseCursor}
					<button
						type="button"
						class="secondary"
						onclick={() => browse(browseCursor ?? undefined)}
					>
						Load more
					</button>
				{/if}
			{/if}
		</section>
	{:else if activeTab === 'scripts'}
		<section>
			<h2>Scripts</h2>
			<p class="muted">
				Group-owned script templates — inherited by every descendant app,
				resolved by name (nearest-owner-wins). Author them via <code>pic apply</code>.
			</p>
			{#if scriptsError}
				{@render loadErrorNote(scriptsError)}
			{:else if !scriptsLoaded}
				<p class="muted">Loading…</p>
			{:else if scripts.length === 0}
				<p class="muted">No scripts declared on this group.</p>
			{:else}
				<table class="grid">
					<thead>
						<tr><th>Name</th><th>Kind</th><th>Version</th><th>Updated</th></tr>
					</thead>
					<tbody>
						{#each scripts as s (s.id)}
							<tr>
								<td class="mono">{s.name}</td>
								<td>
									<span class="badge" class:badge-muted={s.kind === 'module'}>{s.kind}</span>
								</td>
								<td>v{s.version}</td>
								<td>{fmtTime(s.updated_at)}</td>
							</tr>
						{/each}
					</tbody>
				</table>
			{/if}
		</section>
	{:else if activeTab === 'triggers'}
		<section>
			<h2>Trigger templates</h2>
			<p class="muted">
				Event trigger templates declared on this group. Each fires a group-owned
				handler on a matching descendant-app (or shared-collection) event. Author
				them via <code>pic apply</code>.
			</p>
			{#if triggersError}
				{@render loadErrorNote(triggersError)}
			{:else if !triggersLoaded}
				<p class="muted">Loading…</p>
			{:else if triggers.length === 0}
				<p class="muted">No trigger templates declared on this group.</p>
			{:else}
				<table class="grid">
					<thead>
						<tr><th>Kind</th><th>Target</th><th>Handler</th><th>Flags</th></tr>
					</thead>
					<tbody>
						{#each triggers as t (t.kind + '/' + t.target + '/' + t.script)}
							<tr>
								<td><span class="badge">{t.kind}</span></td>
								<td class="mono small">{t.target}</td>
								<td class="mono">{t.script}</td>
								<td>
									{#if !t.enabled}<span class="badge badge-muted">disabled</span>{/if}
									{#if t.sealed}<span class="badge badge-sealed" title="Non-suppressible">sealed</span>{/if}
									{#if t.shared}<span class="badge badge-shared" title="Watches the shared collection">shared</span>{/if}
								</td>
							</tr>
						{/each}
					</tbody>
				</table>
			{/if}
		</section>
	{:else if activeTab === 'routes'}
		<section>
			<h2>Route templates</h2>
			<p class="muted">
				HTTP route templates declared on this group — each descendant app serves
				them (nearest-owner shadowing). Author them via <code>pic apply</code>.
			</p>
			{#if routesError}
				{@render loadErrorNote(routesError)}
			{:else if !routesLoaded}
				<p class="muted">Loading…</p>
			{:else if routes.length === 0}
				<p class="muted">No route templates declared on this group.</p>
			{:else}
				<table class="grid">
					<thead>
						<tr>
							<th>Method</th><th>Path</th><th>Handler</th><th>Dispatch</th><th>Flags</th>
						</tr>
					</thead>
					<tbody>
						{#each routes as r (r.method + ' ' + r.path + ' ' + r.script)}
							<tr>
								<td><span class="badge">{r.method}</span></td>
								<td class="mono small">
									{r.path}
									{#if r.path_kind !== 'exact'}<span class="muted">({r.path_kind})</span>{/if}
								</td>
								<td class="mono">{r.script}</td>
								<td>{r.dispatch}</td>
								<td>
									{#if !r.enabled}<span class="badge badge-muted">disabled</span>{/if}
									{#if r.sealed}<span class="badge badge-sealed" title="Non-suppressible">sealed</span>{/if}
								</td>
							</tr>
						{/each}
					</tbody>
				</table>
			{/if}
		</section>
	{:else if activeTab === 'suppressions'}
		<section>
			<h2>Suppressions</h2>
			<p class="muted">
				Inherited templates this group declines for its whole subtree
				(<code>[suppress]</code> in the manifest). A sealed template can't be
				declined. Author via <code>pic apply</code>.
			</p>
			{#if suppressionsError}
				{@render loadErrorNote(suppressionsError)}
			{:else if !suppressionsLoaded}
				<p class="muted">Loading…</p>
			{:else if suppressions.length === 0}
				<p class="muted">No suppressions declared on this group.</p>
			{:else}
				<table class="grid">
					<thead>
						<tr><th>Target kind</th><th>Reference</th></tr>
					</thead>
					<tbody>
						{#each suppressions as sup (sup.target_kind + '/' + sup.reference)}
							<tr>
								<td><span class="badge">{sup.target_kind}</span></td>
								<td class="mono">{sup.reference}</td>
							</tr>
						{/each}
					</tbody>
				</table>
			{/if}
		</section>
	{:else if activeTab === 'extension-points'}
		<section>
			<h2>Extension points</h2>
			<p class="muted">
				Opt-in polymorphism markers declared on this group — a descendant app can
				override each with its own module; otherwise the group's default body is
				used. Author via <code>pic apply</code>.
			</p>
			{#if extPointsError}
				{@render loadErrorNote(extPointsError)}
			{:else if !extPointsLoaded}
				<p class="muted">Loading…</p>
			{:else if extPoints.length === 0}
				<p class="muted">No extension points declared on this group.</p>
			{:else}
				<table class="grid">
					<thead>
						<tr><th>Name</th><th>Declared here</th><th>Provider</th></tr>
					</thead>
					<tbody>
						{#each extPoints as ep (ep.name)}
							<tr>
								<td class="mono">{ep.name}</td>
								<td>
									{#if ep.declared_here}
										<span class="badge">here</span>
									{:else}
										<span class="badge badge-muted">inherited</span>
									{/if}
								</td>
								<td class="mono small">{ep.provider ?? '—'}</td>
							</tr>
						{/each}
					</tbody>
				</table>
			{/if}
		</section>
	{:else if activeTab === 'dead-letters'}
		<section>
			<h2>Shared-queue dead-letters</h2>
			<p class="muted">
				Exhausted messages from this group's shared durable queues. Read-only —
				replay/resolve is not exposed for shared queues.
			</p>
			<label class="inline-check">
				<input
					type="checkbox"
					bind:checked={dlUnresolvedOnly}
					onchange={() => {
						deadLettersLoaded = false;
						void loadDeadLetters();
					}}
				/>
				Unresolved only
			</label>
			{#if deadLettersError}
				{@render loadErrorNote(deadLettersError)}
			{:else if !deadLettersLoaded}
				<p class="muted">Loading…</p>
			{:else if deadLetters.length === 0}
				<p class="muted">No dead-letters on this group.</p>
			{:else}
				<table class="grid">
					<thead>
						<tr>
							<th>Collection</th><th>Source</th><th>Op</th><th>Attempts</th>
							<th>Last error</th><th>Created</th><th>Resolved</th>
						</tr>
					</thead>
					<tbody>
						{#each deadLetters as dl (dl.id)}
							<tr>
								<td class="mono">{dl.collection}</td>
								<td class="mono small">{dl.source}</td>
								<td>{dl.op}</td>
								<td>{dl.attempt_count}</td>
								<td class="mono small">{preview(dl.last_error, 80)}</td>
								<td>{fmtTime(dl.created_at)}</td>
								<td>{dl.resolved_at ? fmtTime(dl.resolved_at) : '—'}</td>
							</tr>
						{/each}
					</tbody>
				</table>
			{/if}
		</section>
	{:else if activeTab === 'ownership'}
		<section>
			<h2>Ownership</h2>
			<p class="muted">
				Multi-repo projects (repo roots) and the count of group nodes each owns.
				A group is claimed by the first <code>pic apply</code> declaring a
				<code>[project]</code>. Per-group approval policy has no live view — it is
				previewed via <code>pic plan</code>.
			</p>
			{#if projectsError}
				{@render loadErrorNote(projectsError)}
			{:else if !projectsLoaded}
				<p class="muted">Loading…</p>
			{:else if projects.length === 0}
				<p class="muted">No projects registered on this instance.</p>
			{:else}
				<table class="grid">
					<thead>
						<tr><th>Slug</th><th>Name</th><th>Owned groups</th><th>Created</th></tr>
					</thead>
					<tbody>
						{#each projects as p (p.slug)}
							<tr>
								<td class="mono">{p.slug}</td>
								<td>{p.name}</td>
								<td>{p.owned_groups}</td>
								<td>{fmtTime(p.created_at)}</td>
							</tr>
						{/each}
					</tbody>
				</table>
			{/if}
		</section>
	{:else if activeTab === 'settings' && canManage}
		<section>
			<h2>Settings</h2>
			<form class="create-form" onsubmit={saveSettings}>
				<label>
					<span>Name</span>
					<input bind:value={editName} required />
				</label>
				<label>
					<span>Description</span>
					<input bind:value={editDescription} />
				</label>
				<p class="muted small">
					The slug <code>/{group.slug}</code> is frozen and cannot be changed.
				</p>
				{#if settingsError}
					<div class="error">{settingsError}</div>
				{/if}
				<div class="actions">
					<button type="submit" disabled={savingSettings}>
						{savingSettings ? 'Saving…' : 'Save changes'}
					</button>
				</div>
			</form>

			<h2>Move group</h2>
			<form class="create-form" onsubmit={submitReparent}>
				<label>
					<span>Parent group</span>
					<select bind:value={reparentTarget}>
						<option value="">— root —</option>
						{#each reparentOptions as g (g.id)}
							<option value={g.id}>{g.name} (/{g.slug})</option>
						{/each}
					</select>
				</label>
				{#if reparentError}
					<div class="error">{reparentError}</div>
				{/if}
				<div class="actions">
					<button type="submit" disabled={reparenting}>
						{reparenting ? 'Moving…' : 'Move group'}
					</button>
				</div>
			</form>

			<div class="danger-zone">
				<h3>Delete group</h3>
				<p class="muted">
					Removes this group. The group must be empty first — it can't have any
					subgroups or apps.
				</p>
				<button type="button" class="danger" onclick={askDelete}>Delete group</button>
			</div>
		</section>
	{/if}

	{#if confirmingDelete}
		<ConfirmModal
			title="Delete group “{group.name}”"
			variant="danger"
			confirmLabel="Delete group"
			busyLabel="Deleting…"
			busy={deleting}
			onConfirm={confirmDelete}
			onCancel={() => (confirmingDelete = false)}
		>
			<p>
				This permanently removes the group <strong>{group.name}</strong>. The
				group must be empty — if it still has subgroups or apps, the delete is
				rejected and the reason appears below.
			</p>
			{#if deleteError}
				<p class="modal-error">{deleteError}</p>
			{/if}
		</ConfirmModal>
	{/if}

	{#if memberToRemove}
		<ConfirmModal
			title="Remove {memberToRemove.username} from {group.name}"
			variant="danger"
			confirmLabel="Remove member"
			busyLabel="Removing…"
			busy={removingMember}
			onConfirm={confirmRemoveMember}
			onCancel={() => (memberToRemove = null)}
		>
			<p>
				<strong>{memberToRemove.username}</strong> will lose access to this
				group. Their other memberships and account are untouched.
			</p>
			{#if removeMemberError}
				<p class="modal-error">{removeMemberError}</p>
			{/if}
		</ConfirmModal>
	{/if}
{:else if loading}
	<p class="muted">Loading…</p>
{:else if loadError}
	<p class="error">{loadError}</p>
{/if}

<style>
	.page-head {
		margin-bottom: 1rem;
	}
	.breadcrumb {
		display: flex;
		flex-wrap: wrap;
		gap: 0.4rem;
		align-items: baseline;
		font-size: 0.875rem;
		color: var(--text-muted);
	}
	.breadcrumb a {
		color: var(--color-link);
		text-decoration: none;
	}
	.breadcrumb code {
		background: var(--bg-elevated);
		padding: 0.1rem 0.35rem;
		border-radius: var(--radius-sm);
	}
	h1 {
		margin: 0.25rem 0 0.25rem;
		font-size: 1.5rem;
	}

	h2 {
		font-size: 1.125rem;
		margin: 1.5rem 0 1rem;
	}

	h3 {
		font-size: 1rem;
		margin: 1.5rem 0 0.5rem;
	}

	button {
		background: #38bdf8;
		color: #0b1220;
		border: none;
		padding: 0.5rem 1rem;
		border-radius: 0.375rem;
		font-weight: 600;
		cursor: pointer;
	}

	button.secondary {
		background: transparent;
		color: #38bdf8;
		border: 1px solid #334155;
		padding: 0.3rem 0.7rem;
		font-weight: 500;
	}

	a.secondary {
		color: #38bdf8;
		text-decoration: none;
		border: 1px solid #334155;
		padding: 0.3rem 0.7rem;
		border-radius: 0.375rem;
		font-size: 0.85rem;
		margin-right: 0.4rem;
	}

	button.danger {
		background: #7f1d1d;
		color: #fecaca;
	}

	button:disabled {
		opacity: 0.5;
		cursor: not-allowed;
	}

	.muted {
		color: #64748b;
	}

	.small {
		font-size: 0.85rem;
	}

	.error {
		border: 1px solid #b91c1c;
		background: #450a0a;
		color: #fecaca;
		padding: 1rem;
		border-radius: 0.5rem;
		margin: 1rem 0;
	}

	.modal-error {
		color: #fca5a5;
	}

	.create-form {
		background: #1e293b;
		border-radius: 0.5rem;
		padding: 1.25rem;
		margin-bottom: 1.5rem;
		display: flex;
		flex-direction: column;
		gap: 0.75rem;
	}

	.create-form .row {
		display: grid;
		grid-template-columns: 1fr 2fr;
		gap: 0.75rem;
	}

	.create-form label {
		display: flex;
		flex-direction: column;
		gap: 0.25rem;
		font-size: 0.85rem;
		color: #cbd5e1;
	}

	.create-form .row > label.grow {
		grid-column: span 2;
	}

	.create-form input,
	.create-form select {
		background: #0b1220;
		color: #e2e8f0;
		border: 1px solid #334155;
		border-radius: 0.375rem;
		padding: 0.5rem 0.75rem;
		font: inherit;
	}

	.collection-form {
		display: flex;
		align-items: flex-end;
		gap: 0.75rem;
		margin-bottom: 1rem;
	}
	.collection-form label {
		display: flex;
		flex-direction: column;
		gap: 0.25rem;
		font-size: 0.85rem;
		color: #cbd5e1;
	}
	.collection-form input,
	.collection-form select {
		background: #0b1220;
		color: #e2e8f0;
		border: 1px solid #334155;
		border-radius: 0.375rem;
		padding: 0.5rem 0.75rem;
		font: inherit;
	}

	.inline-check {
		display: inline-flex;
		align-items: center;
		gap: 0.4rem;
		font-size: 0.85rem;
		color: #cbd5e1;
		margin-bottom: 1rem;
	}

	.actions {
		display: flex;
		justify-content: flex-end;
		gap: 0.5rem;
	}

	.list {
		list-style: none;
		padding: 0;
		margin: 0;
		display: flex;
		flex-direction: column;
		gap: 0.5rem;
	}

	.list a {
		display: flex;
		flex-direction: column;
		gap: 0.25rem;
		padding: 0.85rem 1rem;
		background: #1e293b;
		border-radius: 0.375rem;
		text-decoration: none;
		color: inherit;
	}

	.list a:hover {
		background: #283549;
	}

	.primary {
		display: flex;
		gap: 0.5rem;
		align-items: baseline;
	}

	.secondary {
		font-size: 0.875rem;
	}

	.danger-zone {
		margin-top: 2rem;
		padding: 1rem;
		border: 1px solid #7f1d1d;
		border-radius: 0.5rem;
		background: #1e0a0a;
	}

	.table {
		display: flex;
		flex-direction: column;
		gap: 0.25rem;
	}

	.table .row {
		display: grid;
		grid-template-columns: 2fr 1fr 1fr 1fr 3rem;
		gap: 0.75rem;
		padding: 0.85rem 1rem;
		background: #1e293b;
		border-radius: 0.375rem;
		align-items: center;
		margin: 0;
	}

	.table .head-row {
		background: transparent;
		padding: 0.25rem 1rem;
		color: #64748b;
		font-size: 0.75rem;
		text-transform: uppercase;
		letter-spacing: 0.05em;
	}

	.table .member-row.inactive {
		opacity: 0.55;
	}

	.table .member-row strong {
		margin-right: 0.4rem;
	}

	.table .member-row .muted {
		font-size: 0.8rem;
	}

	.table .actions-col {
		display: flex;
		justify-content: flex-end;
	}

	/* Read-only inspection grids. */
	table.grid {
		width: 100%;
		border-collapse: collapse;
		font-size: 0.9rem;
		margin-top: 0.5rem;
	}
	table.grid th,
	table.grid td {
		text-align: left;
		padding: 0.45rem 0.6rem;
		border-bottom: 1px solid #1e293b;
		vertical-align: top;
	}
	table.grid th {
		color: #64748b;
		font-size: 0.72rem;
		text-transform: uppercase;
		letter-spacing: 0.05em;
		font-weight: 600;
	}
	.mono {
		font-family: monospace;
	}
	pre.value {
		margin: 0;
		padding: 0.5rem;
		background: #0b1220;
		border-radius: 0.375rem;
		white-space: pre-wrap;
		word-break: break-all;
		font-size: 0.8rem;
	}

	.badge {
		display: inline-block;
		background: #0b1220;
		border: 1px solid #334155;
		color: #cbd5e1;
		border-radius: var(--radius-pill, 999px);
		padding: 0.05rem 0.45rem;
		font-size: 0.72rem;
		font-weight: 600;
		margin-right: 0.3rem;
	}
	.badge-muted {
		color: #64748b;
	}
	.badge-sealed {
		border-color: #b45309;
		color: #fbbf24;
	}
	.badge-shared {
		border-color: #0e7490;
		color: #22d3ee;
	}

	.hint {
		color: #64748b;
		font-size: 0.85rem;
		margin: 0.5rem 0 1rem;
	}
	.hint-chip {
		background: #0b1220;
		color: #e2e8f0;
		border: 1px solid #334155;
		border-radius: 0.25rem;
		padding: 0.1rem 0.4rem;
		font-size: 0.8rem;
		cursor: pointer;
		font-family: monospace;
		font-weight: 500;
	}
	.hint-chip:hover {
		border-color: #38bdf8;
	}
</style>
