// Thin client for the PiCloud control-plane and data-plane APIs.
//
// The dashboard primarily targets `/api/v1/admin/*` (manager). The
// data-plane (`/api/v1/execute/*`, orchestrator) is reachable through
// the same Caddy upstream so the "Test invoke" panel can hit it
// without any cross-origin gymnastics.

import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { browser } from '$app/environment';
import { clearSession, getToken, setSession, type InstanceRole } from './auth';

export type { InstanceRole };

export interface ScriptSandbox {
	max_operations?: number;
	max_string_size?: number;
	max_array_size?: number;
	max_map_size?: number;
	max_call_levels?: number;
	max_expr_depth?: number;
}

export type ScriptKind = 'endpoint' | 'module';

export interface Script {
	id: string;
	app_id: string;
	name: string;
	description: string | null;
	version: number;
	source: string;
	/** v1.1.3 — 'endpoint' (default) handles routes/triggers; 'module' is imported by other scripts. */
	kind: ScriptKind;
	timeout_seconds: number;
	memory_limit_mb: number;
	sandbox: ScriptSandbox;
	created_at: string;
	updated_at: string;
}

export interface App {
	id: string;
	slug: string;
	name: string;
	description: string | null;
	created_at: string;
	updated_at: string;
}

export type AppRole = 'app_admin' | 'editor' | 'viewer';

export interface Group {
	id: string;
	parent_id: string | null;
	slug: string;
	name: string;
	description: string | null;
	structure_version: number;
	created_at: string;
	updated_at: string;
}

export interface GroupDetail extends Group {
	/** Root → … → this node breadcrumb. */
	path: Group[];
	subgroups: Group[];
	apps: App[];
}

export interface GroupMember {
	user_id: string;
	username: string;
	email: string | null;
	instance_role: InstanceRole;
	is_active: boolean;
	role: AppRole;
	created_at: string;
}

export interface CreateGroupInput {
	slug: string;
	name: string;
	description?: string | null;
	/** Parent group slug or id; omit (or null) for a root group. */
	parent?: string | null;
}

export interface PatchGroupInput {
	name?: string;
	description?: string | null;
}

export type DomainShape = 'exact' | 'wildcard' | 'parameterized';

export interface AppDomain {
	id: string;
	app_id: string;
	pattern: string;
	shape: DomainShape;
	shape_key: string;
	created_at: string;
}

export interface AppLookupResponse {
	id: string;
	slug: string;
	name: string;
	description: string | null;
	created_at: string;
	updated_at: string;
	/// Present only when the requested slug was a retired redirect.
	redirect_to?: string;
	/// The caller's role on this app — owners are implicit `app_admin`,
	/// admins implicit `editor`, members carry their `app_members.role`.
	/// `null` only when a member somehow reaches the endpoint without
	/// a membership (the server normally 403s first).
	my_role: AppRole | null;
}

export interface SlugCheckResponse {
	ok: boolean;
	conflict_kind: 'current' | 'historical' | 'invalid' | 'reserved' | null;
	current_app: App | null;
	reason: string | null;
}

export interface CreateAppInput {
	slug: string;
	name: string;
	description?: string | null;
	force_takeover?: boolean;
	/** Parent group slug or id; omit for the root group. */
	group?: string | null;
}

export interface PatchAppInput {
	name?: string;
	description?: string | null;
	slug?: string;
	force_takeover?: boolean;
}

export type HostKind = 'any' | 'strict' | 'wildcard';
export type PathKind = 'exact' | 'prefix' | 'param';
export type DispatchMode = 'sync' | 'async';

export interface Route {
	id: string;
	app_id: string;
	script_id: string;
	host_kind: HostKind;
	host: string;
	host_param_name: string | null;
	path_kind: PathKind;
	path: string;
	method: string | null;
	dispatch_mode: DispatchMode;
	created_at: string;
}

export interface RouteInput {
	host_kind: HostKind;
	host?: string;
	host_param_name?: string | null;
	path_kind: PathKind;
	path: string;
	method?: string | null;
	dispatch_mode?: DispatchMode;
}

export interface CheckRouteResponse {
	ok: boolean;
	conflicting_route: Route | null;
	conflict_reason: string | null;
}

export interface MatchRouteResponse {
	matched: null | {
		route_id: string;
		script_id: string;
		params: Record<string, string>;
		rest: string | null;
		host_param: [string, string] | null;
	};
}

export interface VersionInfo {
	product: string;
	sdk: string;
	api: number;
	schema: number;
	wire: number;
	public_base_url: string;
}

export type ExecutionStatus = 'success' | 'error' | 'timeout' | 'budget_exceeded';

export interface ScriptLogEntry {
	timestamp: string;
	level: 'trace' | 'info' | 'warn' | 'error';
	message: string;
	data: unknown;
}

export interface ExecutionLog {
	id: string;
	script_id: string;
	request_id: string;
	request_path: string;
	request_headers: Record<string, string>;
	request_body: unknown;
	response_code: number | null;
	response_body: unknown;
	script_logs: ScriptLogEntry[];
	duration_ms: number;
	status: ExecutionStatus;
	created_at: string;
}

export interface CreateScriptInput {
	app_id: string;
	name: string;
	description?: string | null;
	source: string;
	/** Defaults to 'endpoint' server-side if omitted. v1.1.3. */
	kind?: ScriptKind;
	timeout_seconds?: number;
	memory_limit_mb?: number;
}

export interface UpdateScriptInput {
	name?: string;
	description?: string | null;
	source?: string;
	timeout_seconds?: number;
	memory_limit_mb?: number;
	sandbox?: ScriptSandbox;
	/** v1.1.3 — endpoint→module rejected if routes/triggers reference the script. */
	kind?: ScriptKind;
}

export interface DeadLetterRow {
	id: string;
	app_id: string;
	source: string;
	op: string;
	trigger_id: string | null;
	script_id: string | null;
	payload: unknown;
	attempt_count: number;
	first_attempt_at: string;
	last_attempt_at: string;
	last_error: string;
	created_at: string;
	resolved_at: string | null;
	resolution: 'replayed' | 'ignored' | 'handled_by_script' | 'handler_failed' | null;
}

export type TriggerKind =
	| 'kv'
	| 'docs'
	| 'dead_letter'
	| 'cron'
	| 'files'
	| 'pubsub'
	| 'email'
	| 'queue';
export type TriggerDispatchMode = 'sync' | 'async';

/// Per-kind detail, tagged by `kind` to match the Rust serde shape.
export type TriggerDetails =
	| { kind: 'kv'; collection_glob: string; ops: string[] }
	| { kind: 'docs'; collection_glob: string; ops: string[] }
	| { kind: 'dead_letter'; source_filter?: string; trigger_id_filter?: string; script_id_filter?: string }
	| { kind: 'cron'; schedule: string; timezone: string; last_fired_at?: string | null }
	| { kind: 'files'; collection_glob: string; ops: string[] }
	| { kind: 'pubsub'; topic_pattern: string }
	| { kind: 'email'; has_inbound_secret: boolean }
	| { kind: 'queue'; queue_name: string; visibility_timeout_secs: number; last_fired_at?: string | null };

export interface CreateEmailTriggerInput {
	script_id: string;
	/// Shared HMAC secret; null/omitted means the receiver accepts
	/// unsigned POSTs (URL secrecy is then the only guard).
	inbound_secret?: string | null;
}

/// v1.1.8 per-app end-user record returned by the admin users API.
/// Never carries the password hash or session tokens.
export interface AppUser {
	id: string;
	app_id: string;
	email: string;
	display_name: string | null;
	email_verified_at: string | null;
	last_login_at: string | null;
	created_at: string;
	updated_at: string;
	roles: string[];
}

export interface CreateAppUserInput {
	email: string;
	password: string;
	display_name?: string | null;
}

export interface PatchAppUserInput {
	/// Triple-state: present-with-string sets, present-with-null clears,
	/// absent leaves alone. The serde shape on the server is the same.
	display_name?: string | null;
}

export interface ResetPasswordResponse {
	token: string;
	expires_in_seconds: number;
}

export interface RevokeSessionsResponse {
	revoked: number;
}

/// v1.1.8 pending-invitation row.
export interface Invitation {
	id: string;
	app_id: string;
	email: string;
	display_name: string | null;
	roles: string[];
	created_at: string;
	expires_at: string;
}

export interface InvitationTemplate {
	link_base: string;
	from: string;
	subject: string;
	body_template: string;
}

export interface CreateInvitationInput {
	email: string;
	display_name?: string | null;
	roles?: string[];
	/// Optional. Omit to issue an invitation without sending an email
	/// (the admin delivers the token out-of-band).
	template?: InvitationTemplate;
}

/// v1.1.5 file metadata as the admin files endpoint returns it.
export interface FileMeta {
	id: string;
	collection: string;
	name: string;
	content_type: string;
	size: number;
	checksum: string;
	created_at: string;
	updated_at: string;
}

export interface Trigger {
	id: string;
	app_id: string;
	script_id: string;
	kind: TriggerKind;
	enabled: boolean;
	dispatch_mode: TriggerDispatchMode;
	retry_max_attempts: number;
	retry_backoff: 'exponential' | 'linear' | 'constant';
	retry_base_ms: number;
	registered_by_principal: string;
	created_at: string;
	updated_at: string;
	details: TriggerDetails;
}

export interface CreateCronTriggerInput {
	script_id: string;
	schedule: string;
	timezone: string;
	dispatch_mode?: TriggerDispatchMode;
	retry_max_attempts?: number;
	retry_backoff?: 'exponential' | 'linear' | 'constant';
	retry_base_ms?: number;
}

export interface CreatePubsubTriggerInput {
	script_id: string;
	topic_pattern: string;
	dispatch_mode?: TriggerDispatchMode;
	retry_max_attempts?: number;
	retry_backoff?: 'exponential' | 'linear' | 'constant';
	retry_base_ms?: number;
}

// F-U-001: dashboard create surfaces for the four data-plane triggers.
export interface CreateKvTriggerInput {
	script_id: string;
	collection_glob: string;
	ops?: Array<'insert' | 'update' | 'delete'>;
	dispatch_mode?: TriggerDispatchMode;
	retry_max_attempts?: number;
	retry_backoff?: 'exponential' | 'linear' | 'constant';
	retry_base_ms?: number;
}

export interface CreateDocsTriggerInput {
	script_id: string;
	collection_glob: string;
	ops?: Array<'create' | 'update' | 'delete'>;
	dispatch_mode?: TriggerDispatchMode;
	retry_max_attempts?: number;
	retry_backoff?: 'exponential' | 'linear' | 'constant';
	retry_base_ms?: number;
}

export interface CreateFilesTriggerInput {
	script_id: string;
	collection_glob: string;
	ops?: Array<'create' | 'update' | 'delete'>;
	dispatch_mode?: TriggerDispatchMode;
	retry_max_attempts?: number;
	retry_backoff?: 'exponential' | 'linear' | 'constant';
	retry_base_ms?: number;
}

export interface CreateDeadLetterTriggerInput {
	script_id: string;
	source_filter?: string | null;
	trigger_id_filter?: string | null;
	script_id_filter?: string | null;
}

// v1.1.9 — queue:receive trigger.
export interface CreateQueueTriggerInput {
	script_id: string;
	queue_name: string;
	visibility_timeout_secs?: number;
	dispatch_mode?: TriggerDispatchMode;
	retry_max_attempts?: number;
	retry_backoff?: 'exponential' | 'linear' | 'constant';
	retry_base_ms?: number;
}

// v1.2 Workflows — read/run types (admin workflows API).
export interface WorkflowSummary {
	name: string;
	enabled: boolean;
	steps: number;
}

export type WorkflowRunStatus =
	| 'pending'
	| 'running'
	| 'succeeded'
	| 'failed'
	| 'canceled';

export interface WorkflowRun {
	id: string;
	workflow_id?: string;
	status: WorkflowRunStatus;
	error?: string | null;
	workflow_depth: number;
	created_at: string;
	input?: unknown;
	output?: unknown;
	started_at?: string | null;
	finished_at?: string | null;
}

export type WorkflowStepStatus =
	| 'pending'
	| 'ready'
	| 'running'
	| 'succeeded'
	| 'failed'
	| 'skipped';

export interface WorkflowRunStep {
	name: string;
	status: WorkflowStepStatus;
	attempt: number;
	max_attempts: number;
	error?: string | null;
	child_run_id?: string | null;
	depends_on: string[];
	output?: unknown;
}

export interface WorkflowRunDetail {
	run: WorkflowRun;
	steps: WorkflowRunStep[];
}

// v1.1.9 — read-only queue inspection types (admin queues API).
export interface QueueSummary {
	queue_name: string;
	total: number;
	pending: number;
	claimed: number;
}

export interface QueueConsumer {
	trigger_id: string;
	script_id: string;
	script_name: string;
	visibility_timeout_secs: number;
	last_fired_at: string | null;
}

export interface QueueDetail extends QueueSummary {
	consumer: QueueConsumer | null;
}

// v1.1.6 — externally-subscribable realtime topics.
export type TopicAuthMode = 'public' | 'token' | 'session';

export interface Topic {
	name: string;
	external_subscribable: boolean;
	auth_mode: TopicAuthMode;
	created_at: string;
	updated_at: string;
}

export interface CreateTopicInput {
	name: string;
	external_subscribable: boolean;
	auth_mode: TopicAuthMode;
}

export interface UpdateTopicInput {
	external_subscribable?: boolean;
	auth_mode?: TopicAuthMode;
}

export interface SecretListItem {
	name: string;
	updated_at: string;
}

// ---------------------------------------------------------------------------
// v1.2 group read-only inspection surfaces. Every shape below mirrors a Rust
// handler in crates/manager-core/src (vars_api, secrets_api, apply_api,
// group_blobs_api, group_scripts_api, projects_api). All READ-ONLY — group
// shared-collection writes + template authoring happen via the `pic` CLI.
// ---------------------------------------------------------------------------

/// One group var row (vars_api::VarItem). `env` is `*` (env-agnostic) or a
/// concrete env name; a tombstone suppresses an inherited key.
export interface GroupVarItem {
	key: string;
	env: string;
	value: unknown;
	is_tombstone: boolean;
	updated_at: string;
}

/// One group secret row (secrets_api::SecretItem). NAMES + env + updated_at
/// only — values never leave the server.
export interface GroupSecretItem {
	name: string;
	env: string;
	updated_at: string;
}

/// §11.6 shared-collection kinds. kv/docs/files have a data browser; topic and
/// queue are storeless / consumed differently (no per-group data endpoint).
export type GroupCollectionKind = 'kv' | 'docs' | 'files' | 'topic' | 'queue';

/// One declared shared collection (apply_service::CollectionInfo).
export interface GroupCollection {
	name: string;
	kind: GroupCollectionKind;
}

/// One group trigger-template row (apply_service::TriggerTemplateInfo).
export interface GroupTriggerTemplate {
	kind: string;
	target: string;
	script: string;
	enabled: boolean;
	sealed: boolean;
	shared: boolean;
}

/// One group route-template row (apply_service::RouteTemplateInfo).
export interface GroupRouteTemplate {
	method: string;
	host: string;
	path_kind: string;
	path: string;
	script: string;
	dispatch: string;
	enabled: boolean;
	sealed: boolean;
}

/// One group/app suppression row (apply_service::SuppressionInfo).
export interface SuppressionInfo {
	target_kind: string;
	reference: string;
}

/// One extension-point row (apply_service::ExtensionPointInfo).
export interface ExtensionPointInfo {
	name: string;
	declared_here: boolean;
	provider: string | null;
}

/// One shared-collection doc entry (group_blobs_api::DocEntry).
export interface GroupDocEntry {
	id: string;
	data: unknown;
}

/// One group shared-queue dead-letter (group_blobs_api::DeadLetterDto). Note:
/// this shape is distinct from the per-app `DeadLetterRow` — it carries a
/// `collection` and no trigger/script fields.
export interface GroupDeadLetter {
	id: string;
	collection: string;
	source: string;
	op: string;
	attempt_count: number;
	last_error: string;
	created_at: string;
	resolved_at: string | null;
	payload: unknown;
}

/// One project row (projects_api::ProjectListItem).
export interface ProjectListItem {
	slug: string;
	name: string;
	owned_groups: number;
	created_at: string;
}

export interface ExecutionResult {
	status: number;
	headers: Record<string, string>;
	body: unknown;
}

export class ApiError extends Error {
	constructor(
		public readonly status: number,
		message: string,
		public readonly body: unknown
	) {
		super(message);
	}
}

// F-T-003: if the server keeps returning 401 (e.g. the bounce to
// /login itself triggered another 401-emitting request), the previous
// implementation would just keep calling `goto(login)` — a tight loop.
// Track consecutive 401s; after the threshold within the window, hard-
// reload to a static auth-loop page so the user sees something other
// than a spinning dashboard. Any 2xx response resets the count.
let consecutive401Count = 0;
let first401At = 0;
const AUTH_LOOP_THRESHOLD = 3;
const AUTH_LOOP_WINDOW_MS = 10_000;

function loginUrlWithReturnTo(): string {
	if (!browser) return `${base}/login`;
	const here = `${window.location.pathname}${window.location.search}`;
	// Don't loop returnTo back to /login itself.
	if (here === `${base}/login` || here.startsWith(`${base}/login?`)) {
		return `${base}/login`;
	}
	return `${base}/login?returnTo=${encodeURIComponent(here)}`;
}

async function adminRequest<T>(path: string, init?: RequestInit): Promise<T> {
	const headers: Record<string, string> = {
		'content-type': 'application/json',
		...((init?.headers as Record<string, string>) ?? {})
	};
	const tok = getToken();
	if (tok && !headers['authorization']) {
		headers['authorization'] = `Bearer ${tok}`;
	}
	const res = await fetch(path, { ...init, headers });
	const text = await res.text();
	const parsed: unknown = text ? safeJson(text) : null;
	if (res.status === 401) {
		// Token gone stale or never present. Drop any cached session
		// and bounce to login — unless we're already on it, in which
		// case throw and let the login form render the error.
		clearSession();
		const now = Date.now();
		if (now - first401At > AUTH_LOOP_WINDOW_MS) {
			first401At = now;
			consecutive401Count = 1;
		} else {
			consecutive401Count += 1;
		}
		if (browser && !window.location.pathname.endsWith('/login')) {
			if (consecutive401Count >= AUTH_LOOP_THRESHOLD) {
				// Hard-reload via location.assign — Svelte's goto keeps
				// the SPA state which is what's keeping the loop alive.
				window.location.assign(`${base}/login?reason=auth-loop`);
			} else {
				void goto(loginUrlWithReturnTo());
			}
		}
	} else if (res.ok) {
		// Any successful response resets the loop counter — the user has
		// a valid session again.
		consecutive401Count = 0;
		first401At = 0;
	}
	if (!res.ok) {
		const message =
			(parsed && typeof parsed === 'object' && 'error' in parsed
				? String((parsed as { error: unknown }).error)
				: text) || `${res.status} ${res.statusText}`;
		throw new ApiError(res.status, message, parsed);
	}
	return parsed as T;
}

function safeJson(text: string): unknown {
	try {
		return JSON.parse(text) as unknown;
	} catch {
		return text;
	}
}

export type Scope =
	| 'script:read'
	| 'script:write'
	| 'route:write'
	| 'domain:manage'
	| 'log:read'
	| 'app:admin'
	| 'instance:admin';

export const ALL_SCOPES: readonly Scope[] = [
	'script:read',
	'script:write',
	'route:write',
	'domain:manage',
	'log:read',
	'app:admin',
	'instance:admin'
] as const;

export function isInstanceScope(s: Scope): boolean {
	return s.startsWith('instance:');
}

export interface MeDto {
	id: string;
	username: string;
	instance_role: InstanceRole;
	email: string | null;
}

export interface AdminDto {
	id: string;
	username: string;
	is_active: boolean;
	instance_role: InstanceRole;
	email: string | null;
	created_at: string;
	last_login_at: string | null;
}

export interface CreateAdminInput {
	username: string;
	password: string;
	instance_role?: InstanceRole;
	email?: string | null;
}

export interface PatchAdminInput {
	username?: string;
	password?: string;
	is_active?: boolean;
	instance_role?: InstanceRole;
	email?: string | null;
}

export interface AppMemberDto {
	user_id: string;
	username: string;
	email: string | null;
	instance_role: InstanceRole;
	is_active: boolean;
	role: AppRole;
	created_at: string;
}

export interface GrantAppMemberInput {
	user_id: string;
	role: AppRole;
}

export interface ApiKeyDto {
	id: string;
	prefix: string;
	name: string;
	scopes: Scope[];
	app_id: string | null;
	expires_at: string | null;
	last_used_at: string | null;
	created_at: string;
}

export interface MintApiKeyInput {
	name: string;
	scopes: Scope[];
	app_id?: string | null;
	expires_at?: string | null;
}

export interface MintApiKeyResponse extends ApiKeyDto {
	raw_token: string;
}

interface LoginResponse {
	user: MeDto;
	token: string;
	expires_at: string;
}

export const api = {
	health: () => fetch('/healthz').then((r) => r.text()),

	version: () => adminRequest<VersionInfo>('/version'),

	auth: {
		login: async (username: string, password: string): Promise<MeDto> => {
			const r = await adminRequest<LoginResponse>('/api/v1/admin/auth/login', {
				method: 'POST',
				body: JSON.stringify({ username, password })
			});
			setSession(r.user, r.token);
			return r.user;
		},
		logout: async (): Promise<void> => {
			try {
				await adminRequest<null>('/api/v1/admin/auth/logout', { method: 'POST' });
			} finally {
				// Always clear locally — logout is idempotent server-side
				// and we don't want a network blip to strand the SPA in
				// a "logged out on server, still logged in client-side"
				// state.
				clearSession();
			}
		},
		me: () => adminRequest<MeDto>('/api/v1/admin/auth/me')
	},

	admins: {
		list: () => adminRequest<AdminDto[]>('/api/v1/admin/admins'),
		get: (id: string) => adminRequest<AdminDto>(`/api/v1/admin/admins/${id}`),
		create: (input: CreateAdminInput) =>
			adminRequest<AdminDto>('/api/v1/admin/admins', {
				method: 'POST',
				body: JSON.stringify(input)
			}),
		update: (id: string, input: PatchAdminInput) =>
			adminRequest<AdminDto>(`/api/v1/admin/admins/${id}`, {
				method: 'PATCH',
				body: JSON.stringify(input)
			}),
		remove: (id: string) =>
			adminRequest<null>(`/api/v1/admin/admins/${id}`, { method: 'DELETE' })
	},

	apiKeys: {
		list: () => adminRequest<ApiKeyDto[]>('/api/v1/admin/api-keys'),
		mint: (input: MintApiKeyInput) =>
			adminRequest<MintApiKeyResponse>('/api/v1/admin/api-keys', {
				method: 'POST',
				body: JSON.stringify(input)
			}),
		revoke: (id: string) =>
			adminRequest<null>(`/api/v1/admin/api-keys/${id}`, { method: 'DELETE' })
	},

	routes: {
		listForScript: (scriptId: string) =>
			adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`),
		create: (scriptId: string, input: RouteInput) =>
			adminRequest<Route>(`/api/v1/admin/scripts/${scriptId}/routes`, {
				method: 'POST',
				body: JSON.stringify(input)
			}),
		remove: (routeId: string) =>
			adminRequest<null>(`/api/v1/admin/routes/${routeId}`, { method: 'DELETE' }),
		check: (appId: string, input: RouteInput) =>
			adminRequest<CheckRouteResponse>('/api/v1/admin/routes:check', {
				method: 'POST',
				body: JSON.stringify({ ...input, app_id: appId })
			}),
		match: (appId: string, url: string, method = 'GET') =>
			adminRequest<MatchRouteResponse>('/api/v1/admin/routes:match', {
				method: 'POST',
				body: JSON.stringify({ app_id: appId, url, method })
			})
	},

	scripts: {
		list: (opts: { app?: string } = {}) => {
			const qs = opts.app ? `?app=${encodeURIComponent(opts.app)}` : '';
			return adminRequest<Script[]>(`/api/v1/admin/scripts${qs}`);
		},
		get: (id: string) => adminRequest<Script>(`/api/v1/admin/scripts/${id}`),
		create: (input: CreateScriptInput) =>
			adminRequest<Script>('/api/v1/admin/scripts', {
				method: 'POST',
				body: JSON.stringify(input)
			}),
		update: (id: string, input: UpdateScriptInput) =>
			adminRequest<Script>(`/api/v1/admin/scripts/${id}`, {
				method: 'PUT',
				body: JSON.stringify(input)
			}),
		remove: (id: string) =>
			adminRequest<null>(`/api/v1/admin/scripts/${id}`, { method: 'DELETE' }),
		logs: (id: string, opts: { limit?: number; offset?: number } = {}) => {
			const params = new URLSearchParams();
			if (opts.limit !== undefined) params.set('limit', String(opts.limit));
			if (opts.offset !== undefined) params.set('offset', String(opts.offset));
			const qs = params.toString();
			return adminRequest<ExecutionLog[]>(
				`/api/v1/admin/scripts/${id}/logs${qs ? `?${qs}` : ''}`
			);
		}
	},

	apps: {
		list: () => adminRequest<App[]>('/api/v1/admin/apps'),
		get: (idOrSlug: string) =>
			adminRequest<AppLookupResponse>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}`),
		create: (input: CreateAppInput) =>
			adminRequest<App>('/api/v1/admin/apps', {
				method: 'POST',
				body: JSON.stringify(input)
			}),
		update: (idOrSlug: string, input: PatchAppInput) =>
			adminRequest<App>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}`, {
				method: 'PATCH',
				body: JSON.stringify(input)
			}),
		remove: (idOrSlug: string, opts: { force?: boolean } = {}) => {
			const qs = opts.force ? '?force=true' : '';
			return adminRequest<null>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}${qs}`,
				{ method: 'DELETE' }
			);
		},
		slugCheck: (idOrSlug: string, newSlug: string) =>
			adminRequest<SlugCheckResponse>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/slug:check`,
				{
					method: 'POST',
					body: JSON.stringify({ new_slug: newSlug })
				}
			)
	},

	groups: {
		list: () => adminRequest<Group[]>('/api/v1/admin/groups'),
		get: (idOrSlug: string) =>
			adminRequest<GroupDetail>(`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}`),
		create: (input: CreateGroupInput) =>
			adminRequest<Group>('/api/v1/admin/groups', {
				method: 'POST',
				body: JSON.stringify(input)
			}),
		update: (idOrSlug: string, input: PatchGroupInput) =>
			adminRequest<Group>(`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}`, {
				method: 'PATCH',
				body: JSON.stringify(input)
			}),
		reparent: (idOrSlug: string, parent: string | null) =>
			adminRequest<Group>(
				`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/reparent`,
				{ method: 'POST', body: JSON.stringify({ parent }) }
			),
		delete: (idOrSlug: string) =>
			adminRequest<null>(`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}`, {
				method: 'DELETE'
			}),
		members: {
			list: (idOrSlug: string) =>
				adminRequest<GroupMember[]>(
					`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members`
				),
			grant: (idOrSlug: string, input: GrantAppMemberInput) =>
				adminRequest<GroupMember>(
					`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members`,
					{ method: 'POST', body: JSON.stringify(input) }
				),
			update: (idOrSlug: string, userId: string, role: AppRole) =>
				adminRequest<GroupMember>(
					`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members/${userId}`,
					{ method: 'PATCH', body: JSON.stringify({ role }) }
				),
			remove: (idOrSlug: string, userId: string) =>
				adminRequest<null>(
					`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members/${userId}`,
					{ method: 'DELETE' }
				)
		},

		// --- v1.2 read-only inspection surfaces (all GET) --------------
		// The group's OWN vars (not the inherited/resolved view).
		vars: (idOrSlug: string) =>
			adminRequest<{ vars: GroupVarItem[] }>(
				`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/vars`
			),
		// Secret NAMES + env + updated_at only — never values. Requires
		// GroupSecretsWrite; a 403 is surfaced as a permission note, not a crash.
		secrets: (idOrSlug: string) =>
			adminRequest<{ secrets: GroupSecretItem[]; next_cursor: string | null }>(
				`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/secrets`
			),
		// Declared shared collections (kv/docs/files/topic/queue).
		collections: (idOrSlug: string) =>
			adminRequest<GroupCollection[]>(
				`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/collections`
			),
		scripts: (idOrSlug: string) =>
			adminRequest<Script[]>(
				`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/scripts`
			),
		triggers: (idOrSlug: string) =>
			adminRequest<GroupTriggerTemplate[]>(
				`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/triggers`
			),
		routes: (idOrSlug: string) =>
			adminRequest<GroupRouteTemplate[]>(
				`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/routes`
			),
		suppressions: (idOrSlug: string) =>
			adminRequest<SuppressionInfo[]>(
				`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/suppressions`
			),
		extensionPoints: (idOrSlug: string) =>
			adminRequest<ExtensionPointInfo[]>(
				`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/extension-points`
			),
		// Shared-KV browser: list keys, then get a single value.
		groupKv: {
			list: (
				idOrSlug: string,
				collection: string,
				opts: { cursor?: string; limit?: number } = {}
			) => {
				const params = new URLSearchParams();
				params.set('collection', collection);
				if (opts.cursor) params.set('cursor', opts.cursor);
				if (opts.limit !== undefined) params.set('limit', String(opts.limit));
				return adminRequest<{ keys: string[]; next_cursor: string | null }>(
					`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/kv?${params.toString()}`
				);
			},
			get: (idOrSlug: string, collection: string, key: string) =>
				adminRequest<{ value: unknown }>(
					`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/kv/${encodeURIComponent(collection)}/${encodeURIComponent(key)}`
				)
		},
		// Shared-docs browser: list documents (id + data), then get one.
		groupDocs: {
			list: (
				idOrSlug: string,
				collection: string,
				opts: { cursor?: string; limit?: number } = {}
			) => {
				const params = new URLSearchParams();
				params.set('collection', collection);
				if (opts.cursor) params.set('cursor', opts.cursor);
				if (opts.limit !== undefined) params.set('limit', String(opts.limit));
				return adminRequest<{ docs: GroupDocEntry[]; next_cursor: string | null }>(
					`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/docs?${params.toString()}`
				);
			},
			get: (idOrSlug: string, collection: string, docId: string) =>
				adminRequest<{ id: string; data: unknown }>(
					`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/docs/${encodeURIComponent(collection)}/${docId}`
				)
		},
		// Shared-files browser: list metadata + download URL (GET streams bytes).
		groupFiles: {
			list: (
				idOrSlug: string,
				collection: string,
				opts: { cursor?: string; limit?: number } = {}
			) => {
				const params = new URLSearchParams();
				params.set('collection', collection);
				if (opts.cursor) params.set('cursor', opts.cursor);
				if (opts.limit !== undefined) params.set('limit', String(opts.limit));
				return adminRequest<{ files: FileMeta[]; next_cursor: string | null }>(
					`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/files?${params.toString()}`
				);
			},
			downloadUrl: (idOrSlug: string, collection: string, fileId: string): string =>
				`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/files/${encodeURIComponent(collection)}/${fileId}`
		},
		// §11.6 D3 shared-queue dead-letters (newest-first).
		deadLetters: (
			idOrSlug: string,
			opts: { unresolved?: boolean; limit?: number } = {}
		) => {
			const params = new URLSearchParams();
			if (opts.unresolved) params.set('unresolved', 'true');
			if (opts.limit !== undefined) params.set('limit', String(opts.limit));
			const qs = params.toString();
			return adminRequest<GroupDeadLetter[]>(
				`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/dead-letters${qs ? `?${qs}` : ''}`
			);
		}
	},

	projects: {
		list: () => adminRequest<ProjectListItem[]>('/api/v1/admin/projects')
	},

	domains: {
		listForApp: (idOrSlug: string) =>
			adminRequest<AppDomain[]>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/domains`
			),
		create: (idOrSlug: string, pattern: string) =>
			adminRequest<AppDomain>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/domains`,
				{ method: 'POST', body: JSON.stringify({ pattern }) }
			),
		remove: (idOrSlug: string, domainId: string) =>
			adminRequest<null>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/domains/${domainId}`,
				{ method: 'DELETE' }
			)
	},

	appMembers: {
		list: (idOrSlug: string) =>
			adminRequest<AppMemberDto[]>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members`
			),
		add: (idOrSlug: string, input: GrantAppMemberInput) =>
			adminRequest<AppMemberDto>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members`,
				{ method: 'POST', body: JSON.stringify(input) }
			),
		setRole: (idOrSlug: string, userId: string, role: AppRole) =>
			adminRequest<AppMemberDto>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members/${userId}`,
				{ method: 'PATCH', body: JSON.stringify({ role }) }
			),
		remove: (idOrSlug: string, userId: string) =>
			adminRequest<null>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members/${userId}`,
				{ method: 'DELETE' }
			)
	},

	deadLetters: {
		count: (idOrSlug: string) =>
			adminRequest<{ unresolved: number }>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/dead_letters/count`
			),
		list: (idOrSlug: string, opts: { unresolved?: boolean; limit?: number; offset?: number } = {}) => {
			const params = new URLSearchParams();
			if (opts.unresolved) params.set('unresolved', 'true');
			if (opts.limit !== undefined) params.set('limit', String(opts.limit));
			if (opts.offset !== undefined) params.set('offset', String(opts.offset));
			const qs = params.toString();
			return adminRequest<{ dead_letters: DeadLetterRow[] }>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/dead_letters${qs ? `?${qs}` : ''}`
			);
		},
		get: (idOrSlug: string, dlId: string) =>
			adminRequest<DeadLetterRow>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/dead_letters/${dlId}`
			),
		replay: (idOrSlug: string, dlId: string) =>
			adminRequest<null>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/dead_letters/${dlId}/replay`,
				{ method: 'POST' }
			),
		resolve: (idOrSlug: string, dlId: string, reason: string) =>
			adminRequest<null>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/dead_letters/${dlId}/resolve`,
				{ method: 'POST', body: JSON.stringify({ reason }) }
			)
	},

	triggers: {
		list: (idOrSlug: string) =>
			adminRequest<{ triggers: Trigger[] }>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers`
			),
		createCron: (idOrSlug: string, input: CreateCronTriggerInput) =>
			adminRequest<Trigger>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/cron`,
				{ method: 'POST', body: JSON.stringify(input) }
			),
		createPubsub: (idOrSlug: string, input: CreatePubsubTriggerInput) =>
			adminRequest<Trigger>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/pubsub`,
				{ method: 'POST', body: JSON.stringify(input) }
			),
		createEmail: (idOrSlug: string, input: CreateEmailTriggerInput) =>
			adminRequest<Trigger>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/email`,
				{ method: 'POST', body: JSON.stringify(input) }
			),
		createQueue: (idOrSlug: string, input: CreateQueueTriggerInput) =>
			adminRequest<Trigger>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/queue`,
				{ method: 'POST', body: JSON.stringify(input) }
			),
		// F-U-001: matching frontend methods for the four data-plane
		// trigger kinds the backend has long supported.
		createKv: (idOrSlug: string, input: CreateKvTriggerInput) =>
			adminRequest<Trigger>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/kv`,
				{ method: 'POST', body: JSON.stringify(input) }
			),
		createDocs: (idOrSlug: string, input: CreateDocsTriggerInput) =>
			adminRequest<Trigger>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/docs`,
				{ method: 'POST', body: JSON.stringify(input) }
			),
		createFiles: (idOrSlug: string, input: CreateFilesTriggerInput) =>
			adminRequest<Trigger>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/files`,
				{ method: 'POST', body: JSON.stringify(input) }
			),
		createDeadLetter: (idOrSlug: string, input: CreateDeadLetterTriggerInput) =>
			adminRequest<Trigger>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/dead_letter`,
				{ method: 'POST', body: JSON.stringify(input) }
			),
		remove: (idOrSlug: string, triggerId: string) =>
			adminRequest<null>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/${triggerId}`,
				{ method: 'DELETE' }
			)
	},

	queues: {
		list: (idOrSlug: string) =>
			adminRequest<QueueSummary[]>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/queues`
			),
		get: (idOrSlug: string, name: string) =>
			adminRequest<QueueDetail>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/queues/${encodeURIComponent(name)}`
			)
	},

	workflows: {
		list: (idOrSlug: string) =>
			adminRequest<WorkflowSummary[]>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/workflows`
			),
		runs: (idOrSlug: string, name: string) =>
			adminRequest<WorkflowRun[]>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/workflows/${encodeURIComponent(name)}/runs`
			),
		start: (idOrSlug: string, name: string, input: unknown) =>
			adminRequest<WorkflowRun>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/workflows/${encodeURIComponent(name)}/runs`,
				{ method: 'POST', body: JSON.stringify({ input }) }
			),
		run: (idOrSlug: string, runId: string) =>
			adminRequest<WorkflowRunDetail>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/workflow-runs/${encodeURIComponent(runId)}`
			)
	},

	topics: {
		list: (idOrSlug: string) =>
			adminRequest<{ topics: Topic[] }>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/topics`
			),
		create: (idOrSlug: string, input: CreateTopicInput) =>
			adminRequest<Topic>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/topics`, {
				method: 'POST',
				body: JSON.stringify(input)
			}),
		update: (idOrSlug: string, name: string, input: UpdateTopicInput) =>
			adminRequest<Topic>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/topics/${encodeURIComponent(name)}`,
				{ method: 'PATCH', body: JSON.stringify(input) }
			),
		remove: (idOrSlug: string, name: string) =>
			adminRequest<null>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/topics/${encodeURIComponent(name)}`,
				{ method: 'DELETE' }
			)
	},

	files: {
		list: (idOrSlug: string, collection: string, opts: { cursor?: string; limit?: number } = {}) => {
			const params = new URLSearchParams();
			params.set('collection', collection);
			if (opts.cursor) params.set('cursor', opts.cursor);
			if (opts.limit !== undefined) params.set('limit', String(opts.limit));
			return adminRequest<{ files: FileMeta[]; next_cursor: string | null }>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/files?${params.toString()}`
			);
		},
		remove: (idOrSlug: string, collection: string, fileId: string) =>
			adminRequest<null>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/files/${encodeURIComponent(collection)}/${fileId}`,
				{ method: 'DELETE' }
			),
		// Build the admin download URL. GET returns the file bytes
		// inline with Content-Disposition set from the stored filename;
		// the browser handles the download.
		downloadUrl: (idOrSlug: string, collection: string, fileId: string): string =>
			`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/files/${encodeURIComponent(collection)}/${fileId}`
	},

	secrets: {
		// List returns names + last-modified ONLY — values never leave the
		// server (v1.1.7).
		list: (idOrSlug: string) =>
			adminRequest<{ secrets: SecretListItem[]; next_cursor: string | null }>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/secrets`
			),
		// `value` is any JSON value; the dashboard sends a single-line
		// string. Overwrites if the name already exists.
		set: (idOrSlug: string, name: string, value: unknown) =>
			adminRequest<null>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/secrets`, {
				method: 'POST',
				body: JSON.stringify({ name, value })
			}),
		remove: (idOrSlug: string, name: string) =>
			adminRequest<null>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/secrets/${encodeURIComponent(name)}`,
				{ method: 'DELETE' }
			)
	},

	users: {
		list: (idOrSlug: string, opts: { cursor?: string; limit?: number } = {}) => {
			const params = new URLSearchParams();
			if (opts.cursor) params.set('cursor', opts.cursor);
			if (opts.limit !== undefined) params.set('limit', String(opts.limit));
			const qs = params.toString();
			return adminRequest<{ users: AppUser[]; next_cursor: string | null }>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users${qs ? `?${qs}` : ''}`
			);
		},
		get: (idOrSlug: string, userId: string) =>
			adminRequest<AppUser>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users/${userId}`
			),
		create: (idOrSlug: string, input: CreateAppUserInput) =>
			adminRequest<AppUser>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users`, {
				method: 'POST',
				body: JSON.stringify(input)
			}),
		patch: (idOrSlug: string, userId: string, input: PatchAppUserInput) =>
			adminRequest<AppUser>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users/${userId}`,
				{ method: 'PATCH', body: JSON.stringify(input) }
			),
		remove: (idOrSlug: string, userId: string) =>
			adminRequest<null>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users/${userId}`,
				{ method: 'DELETE' }
			),
		resetPassword: (idOrSlug: string, userId: string) =>
			adminRequest<ResetPasswordResponse>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users/${userId}/reset-password`,
				{ method: 'POST' }
			),
		revokeSessions: (idOrSlug: string, userId: string) =>
			adminRequest<RevokeSessionsResponse>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users/${userId}/revoke-sessions`,
				{ method: 'POST' }
			)
	},

	appInvitations: {
		list: (idOrSlug: string) =>
			adminRequest<{ invitations: Invitation[] }>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/invitations`
			),
		create: (idOrSlug: string, input: CreateInvitationInput) =>
			adminRequest<Invitation>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/invitations`,
				{ method: 'POST', body: JSON.stringify(input) }
			),
		remove: (idOrSlug: string, inviteId: string) =>
			adminRequest<null>(
				`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/invitations/${inviteId}`,
				{ method: 'DELETE' }
			)
	},

	execute: async (
		id: string,
		body: unknown,
		extraHeaders?: Record<string, string>
	): Promise<ExecutionResult> => {
		const res = await fetch(`/api/v1/execute/${id}`, {
			method: 'POST',
			headers: { 'content-type': 'application/json', ...(extraHeaders ?? {}) },
			body: body === undefined ? '{}' : JSON.stringify(body)
		});
		const text = await res.text();
		const parsedBody: unknown = text ? safeJson(text) : null;
		const headers: Record<string, string> = {};
		res.headers.forEach((value, key) => {
			headers[key] = value;
		});
		return { status: res.status, headers, body: parsedBody };
	}
};
