/**
 * Server-only Sprout wiring for the admin app.
 *
 * Declares the resources once, materializes a store, and hands routes an
 * `OpContext`. Phase 0 runs the demo schema on an in-memory pglite database
 * (the same store the core e2e tests use); a project's real `sprout.config`
 * + Postgres store slot in here later without touching the routes.
 *
 * Auth (task 22) is better-auth: the request's *session* is the source of the
 * current user and `user.role` drives RBAC. `resolveUser` reads the session
 * first; a dev fallback (`x-maxstack-role` header, else a default admin) keeps
 * the local admin usable and unit tests header-driven, and is switched off by
 * `MAXSTACK_AUTH_STRICT=1` so a real deployment denies anonymous requests.
 * Denial needs a rule to deny against: `authorize()` is open-by-default, so
 * project spec entities register with `AUTHENTICATED_WRITES` when the auth
 * bundle is installed (issue #32) — strict mode then actually 403s anonymous
 * writes instead of waving them through.
 */

import {
	AUTHENTICATED_WRITES,
	createAccessContext,
	createSpecStore,
	opCount,
	opGetMany,
	opList,
	type ReferenceMap,
	type ResourceCapabilities,
	ResourceRegistry,
	type Row,
	registerSpecEntities,
	resolveReferences,
	resourceCapabilities,
	type SproutResource,
	type SproutStore,
	type SproutUser,
} from '@maxstack/core'
// The DB-driver selection (pglite + postgres.js) is a server-only subpath so it
// never lands in a client bundle — see the note in core's `sprout/index.ts`.
import {
	createBackend,
	pgliteBackend,
	resolveBackendConfig,
	type StoreBackend,
} from '@maxstack/core/backend'
import {
	article,
	author,
	comment,
	createDemoDb,
	tag,
	task,
} from '@maxstack/core/demo'
import { ApiKeyService } from '@maxstack/features/api-keys'
import {
	type AuditReader,
	type AuditSink,
	createMemoryAuditSink,
} from '@maxstack/features/audit'
import {
	AUTH_DDL,
	type Auth,
	user as authUserTable,
	createAuth,
	resolveSproutUser,
} from '@maxstack/features/auth'
import { BUNDLES, type Bundle, seedBundles } from '@maxstack/features/bundle'
import { schedulePurgeJob } from '@maxstack/features/compliance'
import {
	seedDemoData as genericSeedDemoData,
	hasAnyData,
} from '@maxstack/features/demo-mode'
import { createDrizzleJobStore, JobQueue } from '@maxstack/features/jobs'
import { type WebhookEvent, WebhookService } from '@maxstack/features/webhooks'
import {
	createFileSpecStore,
	createInMemorySpecStore,
	defaultCheckRunner,
	defaultGeneratorRunner,
	type PlatformContext,
	type SpecStore,
} from '@maxstack/mcp'
import {
	applyOp,
	manual,
	newSpecSystem,
	type OpId,
	type SpecSystem,
	suggested,
} from '@maxstack/spec'
import { tasklyPRD } from '@maxstack/spec/fixtures'
import { absolutizeDataDir, resolveSpecPath } from './data-dir.server'
import type { McpContext } from './mcp.server'
import {
	groundedEntityShapes,
	missingReferenceBundles,
	schemaFingerprint,
} from './spec-sprout'

export interface WebSprout {
	registry: ResourceRegistry
	store: SproutStore
	/** The store backend (pglite or Postgres) — auth and re-syncs share it. */
	backend: StoreBackend
	auth: Auth
	/** Whether the project has the `auth` bundle installed. When true, spec
	 * entities register with `AUTHENTICATED_WRITES` and the REST API's write
	 * routes reject the anonymous dev fallback (issue #32 follow-up). */
	authInstalled: boolean
}

/**
 * Materialize the better-auth tables on the backend and build the instance. The
 * auth tables live in the *same* database as the app data (one pglite file, one
 * Postgres schema), so sessions and rows share a transactional store — and this
 * is backend-agnostic, which is why auth works unchanged on Postgres. Seeds a
 * dev admin (`admin@maxstack.dev` / `maxstack`) on first boot so the local
 * admin UI has a real, sign-in-able owner instead of a header stand-in.
 */
async function buildAuth(backend: StoreBackend): Promise<Auth> {
	await backend.exec(AUTH_DDL)
	const auth = createAuth({ db: backend.db })
	await seedAuthAdmin(backend, auth)
	return auth
}

async function seedAuthAdmin(backend: StoreBackend, auth: Auth): Promise<void> {
	try {
		await auth.api.signUpEmail({
			body: {
				email: 'admin@maxstack.dev',
				password: 'maxstack',
				name: 'Dev Admin',
			},
		})
	} catch {
		// Email already registered — the seed owner exists; nothing to do.
		return
	}
	// `role` is input:false, so sign-up lands 'member' — elevate the seed owner.
	await backend.exec(
		`UPDATE "user" SET role = 'admin' WHERE email = 'admin@maxstack.dev';`,
	)
}

function buildRegistry(): ResourceRegistry {
	const registry = new ResourceRegistry()
	registry.register(author, {
		group: 'Content',
		icon: '✍️',
		titleField: 'name',
		access: {
			read: 'public',
			create: 'authenticated',
			update: 'authenticated',
		},
	})
	registry.register(task, {
		group: 'Content',
		icon: '✅',
		titleField: 'title',
		access: {
			read: 'public',
			create: 'authenticated',
			update: 'authenticated',
			delete: 'admin',
		},
	})
	// The rich-input showcase (task 39): a markdown body, an image upload, and a
	// password all render the right editor from inference alone.
	registry.register(article, {
		group: 'Content',
		icon: '📝',
		titleField: 'title',
		access: {
			read: 'public',
			create: 'authenticated',
			update: 'authenticated',
			delete: 'admin',
		},
	})
	// The array-reference showcase (task 38): `article.tags` holds tag ids,
	// rendered as chips and edited as a multi-select; `comment` gives an article
	// a child count shown without loading the comments.
	registry.register(tag, {
		group: 'Content',
		icon: '🏷️',
		titleField: 'name',
		access: {
			read: 'public',
			create: 'authenticated',
			update: 'authenticated',
		},
	})
	// Soft-delete showcase (issue #59, `ResourceConfig.softDelete`): a moderated
	// "delete" sets `deletedAt` instead of removing the row, so reads exclude it
	// by default but it's recoverable within the retention window via
	// `/comments/trash` (a small admin list + restore affordance) until the
	// scheduled purge job (`@maxstack/features/compliance`) hard-deletes it.
	registry.register(comment, {
		group: 'Content',
		icon: '💬',
		titleField: 'body',
		access: {
			read: 'public',
			create: 'authenticated',
			update: 'authenticated',
		},
		softDelete: true,
	})
	return registry
}

async function seed(sprout: WebSprout): Promise<void> {
	const authors = await sprout.store.list('author', { limit: 1 })
	if (authors.length > 0) return
	const ada = await sprout.store.create('author', { name: 'Ada Lovelace' })
	await sprout.store.create('task', {
		title: 'Wire Sprout into the admin',
		priority: 'high',
		authorId: ada.id,
	})
	await sprout.store.create('task', {
		title: 'Expose the MCP transport',
		priority: 'medium',
		authorId: ada.id,
	})
	const ecosystem = await sprout.store.create('tag', { name: 'Ecosystem' })
	const dx = await sprout.store.create('tag', { name: 'DX' })
	await sprout.store.create('tag', { name: 'Internals' })
	const article = await sprout.store.create('article', {
		title: 'Rich inputs, zero config',
		body: '# Rich inputs\n\nThis body edits in a **markdown** editor and displays via `<MarkdownField>` — inferred from one `markdown: true` flag.',
		rating: 4,
		brandColor: '#4f46e5',
		// An array reference: chips resolve to tag names, no N+1 (task 38).
		tags: [ecosystem.id, dx.id],
	})
	// Comments exist only to be counted, not listed (`<ReferenceManyCount>`).
	await sprout.store.create('comment', {
		articleId: article.id,
		body: 'The inferred markdown editor is a lovely touch.',
	})
	await sprout.store.create('comment', {
		articleId: article.id,
		body: 'Does the array reference batch its resolves?',
	})
}

async function init(): Promise<WebSprout> {
	const registry = buildRegistry()
	const { store, client } = await createDemoDb(registry)
	// Wrap the demo client as a backend so auth shares its in-memory database.
	const backend = pgliteBackend(client)
	const auth = await buildAuth(backend)
	// Demo mode has no project spec entities, so no AUTHENTICATED_WRITES rule and
	// nothing for the write gate to protect — treat auth as not "installed".
	const sprout: WebSprout = {
		registry,
		store,
		backend,
		auth,
		authInstalled: false,
	}
	await seed(sprout)
	return sprout
}

// ---------------------------------------------------------------------------
// Project mode — `MAXSTACK_DATA_DIR` opens a real project: the registry is
// grounded from *that project's spec* (accepted-else-all) instead of the demo
// schema, and rows persist in the selected store backend. That backend is
// `DATABASE_URL` (a real Postgres server) when set, else on-disk pglite under
// `<dataDir>/db` — the same schema, store, and auth run over either (task 22).
// The spec is re-grounded per request (a cheap validated file read); the
// database only re-syncs — additive DDL on the same live backend — when the
// grounded schema's fingerprint actually changed (e.g. an accepted new field).
// ---------------------------------------------------------------------------

interface ProjectSprout extends WebSprout {
	fingerprint: string
}

const projectDataDir = (): string | null => {
	const configured = process.env.MAXSTACK_DATA_DIR?.trim()
	return configured ? absolutizeDataDir(configured) : null
}

async function groundProject(
	dataDir: string,
	current?: ProjectSprout,
): Promise<ProjectSprout> {
	const spec = await getPlatform().spec.load()
	const installed = await readInstalledBundleSlugs(dataDir)
	// Installed bundles shape the grounding (a `reference: 'e-user'` only wires
	// up when auth is present) and the registry (the read-only user resource),
	// so they are part of the schema identity the re-sync check compares.
	const shapes = groundedEntityShapes(spec, { installedBundles: installed })
	const fingerprint = `${schemaFingerprint(shapes)}|${installed.join(',')}`
	if (current && current.fingerprint === fingerprint) return current
	const registry = new ResourceRegistry()
	// Secure-by-default (issue #32): with the auth bundle installed, spec-entity
	// writes require a session ('authenticated'); reads stay public. Without it
	// there is no way to log in, so entities stay open — warn loudly instead.
	registerSpecEntities(registry, shapes, {
		access: installed.includes('auth') ? AUTHENTICATED_WRITES : undefined,
	})
	registerAuthUserResource(registry, installed)
	warnIfOpenApi(installed, shapes.length)
	warnMissingReferenceBundles(spec, installed)
	// The backend is opened once (first grounding) and carried across re-syncs —
	// pglite holds an exclusive on-disk handle that must not be reopened, and a
	// Postgres pool is pointless to churn. Config: DATABASE_URL → Postgres.
	const backend =
		current?.backend ??
		(await createBackend(
			resolveBackendConfig({
				dir: `${dataDir}/db`,
				databaseUrl: process.env.DATABASE_URL,
			}),
		))
	const store = await createSpecStore(backend, registry, shapes)
	const auth = current?.auth ?? (await buildAuth(backend))
	await seedProjectBundles(installed, store)
	return {
		registry,
		store,
		backend,
		auth,
		authInstalled: installed.includes('auth'),
		fingerprint,
	}
}

/** Seed the project's installed bundles (idempotent) via the db-plugins engine. */
async function seedProjectBundles(
	installed: string[],
	store: SproutStore,
): Promise<void> {
	const bundles = installed
		.map((slug) => BUNDLES[slug])
		.filter((b): b is NonNullable<typeof b> => b != null)
	if (bundles.length) await seedBundles(store, bundles)
}

/** The installed bundles that actually carry demo rows — what "load demo
 * data" (issue #60) has to offer. Empty outside project mode. */
async function seededBundles(): Promise<Bundle[]> {
	const dataDir = projectDataDir()
	if (!dataDir) return []
	const installed = await readInstalledBundleSlugs(dataDir)
	return installed
		.map((slug) => BUNDLES[slug])
		.filter((b): b is NonNullable<typeof b> => b != null)
		.filter((b) => (b.runtime.seeds?.length ?? 0) > 0)
}

/** Whether the current project has any registered resource at all — gates the
 * onboarding wizard's / empty-state's "Load demo data" CTA. Bundle seeds are
 * used when a resource has them; every other resource still gets generic
 * sample rows (`genericSeedDemoData` below), so this is "is there anything to
 * seed", not "does a bundle happen to carry fixtures". */
export async function hasDemoData(): Promise<boolean> {
	if (!projectDataDir()) return false
	const { registry } = await getSprout()
	return registry.all().length > 0
}

/**
 * On-demand demo-data load (issue #60): runs the same idempotent bundle-seed
 * mechanism boot already applies (`seedProjectBundles` above) for resources a
 * bundle ships fixtures for, then generically fills in sample rows — via
 * column introspection, `@maxstack/features/demo-mode` — for every other
 * resource that's still empty. Exposed as a user-triggered action for the
 * onboarding wizard / empty-state CTA. Idempotent: an entity that already has
 * rows (from either path) is left alone, so clicking it twice is a no-op.
 */
export async function seedDemoData(): Promise<{
	seeded: boolean
	resources: string[]
}> {
	const bundles = await seededBundles()
	const { store, registry } = await getSprout()
	if (bundles.length) await seedBundles(store, bundles)
	const generic = await genericSeedDemoData({ registry, store })
	return {
		seeded: bundles.length > 0 || generic.seeded.length > 0,
		resources: generic.seeded,
	}
}

/**
 * A fresh install (issue #60): project mode with every registered resource
 * still at zero rows. Drives the home page's onboarding wizard — it shows
 * only until the first row lands (demo-seeded or hand-entered), then gets out
 * of the way on its own, no separate "setup complete" marker to maintain.
 */
export async function isFreshProject(): Promise<boolean> {
	if (!projectDataDir()) return false
	const { registry, store } = await getSprout()
	return !(await hasAnyData(registry, store))
}

// Sessions are better-auth's to create and identity rows its to manage — the
// generic CRUD surface must never write them, whoever asks.
const NEVER = () => false

/**
 * With the auth bundle installed, expose its `user` table as a *read-only*
 * Sprout resource (issue #37): a `reference: 'e-user'` field then gets a real
 * FK picker, `<ReferenceField>` resolution (title = name), and reverse counts,
 * because the referenced table is listable/fetchable like any other resource.
 * Reads require a session (the rows carry emails); writes are denied outright.
 * A spec entity that claims the `user` name wins — same shadowing rule as
 * grounding.
 */
function registerAuthUserResource(
	registry: ResourceRegistry,
	installed: string[],
): void {
	if (!installed.includes('auth') || registry.has('user')) return
	registry.register(authUserTable, {
		group: 'Auth',
		icon: '👤',
		titleField: 'name',
		access: {
			read: 'authenticated',
			create: NEVER,
			update: NEVER,
			delete: NEVER,
		},
	})
}

// One warning per process — grounding re-runs whenever the schema fingerprint
// changes, and the posture doesn't change with it.
let warnedOpenApi = false
let warnedMissingReferenceBundles = false

/** A spec that references a virtual entity (`e-user`) whose bundle is not
 * installed grounds those fields as plain columns — no picker, no resolution.
 * Say so once, with the fix. */
function warnMissingReferenceBundles(
	spec: SpecSystem,
	installed: string[],
): void {
	if (warnedMissingReferenceBundles) return
	const missing = missingReferenceBundles(spec, installed)
	if (missing.length === 0) return
	warnedMissingReferenceBundles = true
	console.warn(
		`⚠ maxstack: the spec references bundle-provided entities, but the ${missing.join(
			', ',
		)} bundle${missing.length > 1 ? 's are' : ' is'} not installed — those ` +
			'reference fields fall back to plain columns (no FK picker, no title ' +
			`resolution). Run \`maxstack add ${missing.join(' ')}\` to wire them up.`,
	)
}

/** The loud dev-time counterpart of `maxstack deploy`'s posture warning (issue
 * #32): without the auth bundle, no spec entity carries an access rule, and
 * `authorize()` is open-by-default — every REST/MCP write is anonymous-writable. */
function warnIfOpenApi(installed: string[], entityCount: number): void {
	if (warnedOpenApi || installed.includes('auth') || entityCount === 0) return
	warnedOpenApi = true
	console.warn(
		'⚠ maxstack: no auth bundle installed — the REST API is OPEN: anonymous ' +
			`requests can create/update/delete every spec entity (${entityCount}). ` +
			'Fine for a local data dir; do not deploy this. Run `maxstack add auth` ' +
			'to require a session for writes.',
	)
}

async function readInstalledBundleSlugs(dataDir: string): Promise<string[]> {
	const { readFile } = await import('node:fs/promises')
	const { dirname, resolve } = await import('node:path')
	let dir = resolve(dataDir)
	for (let i = 0; i < 6; i++) {
		try {
			const raw = await readFile(resolve(dir, 'maxstack.json'), 'utf8')
			const config = JSON.parse(raw) as { bundles?: { slug: string }[] }
			return (config.bundles ?? []).map((b) => b.slug)
		} catch {
			// no maxstack.json here — try the parent
		}
		const parent = dirname(dir)
		if (parent === dir) break
		dir = parent
	}
	return []
}

// Module-singleton across dev HMR reloads: the demo pglite is in-memory, so a
// fresh instance per reload would drop seeded rows; the project client holds
// an exclusive on-disk pglite that must not be reopened concurrently.
const globalScope = globalThis as typeof globalThis & {
	__maxstackSprout?: Promise<WebSprout>
	__maxstackProjectSprout?: Promise<ProjectSprout>
}

export function getSprout(): Promise<WebSprout> {
	const dataDir = projectDataDir()
	if (dataDir) {
		const previous = globalScope.__maxstackProjectSprout
		globalScope.__maxstackProjectSprout = previous
			? previous.then((state) => groundProject(dataDir, state))
			: groundProject(dataDir)
		return globalScope.__maxstackProjectSprout
	}
	globalScope.__maxstackSprout ??= init()
	return globalScope.__maxstackSprout
}

/** The better-auth instance for this request's backend (session + `/api/auth/*`). */
export async function getAuth(): Promise<Auth> {
	const { auth } = await getSprout()
	return auth
}

const authStrict = (): boolean =>
	process.env.MAXSTACK_AUTH_STRICT === '1' ||
	process.env.MAXSTACK_AUTH_STRICT === 'true'

/** Idempotent DDL for the `api_key` table (mirrors `preferences/schema.ts`'s
 * `PREFERENCES_DDL`, `IF NOT EXISTS`-guarded for the running app's live db). */
const API_KEYS_LIVE_DDL = `
CREATE TABLE IF NOT EXISTS api_key (
  id text PRIMARY KEY,
  user_id text NOT NULL,
  name text NOT NULL,
  prefix text NOT NULL,
  token_hash text NOT NULL UNIQUE,
  scope jsonb NOT NULL,
  created_at timestamp NOT NULL DEFAULT now(),
  last_used_at timestamp,
  revoked_at timestamp
);
`

const apiKeyAuthScope = globalThis as typeof globalThis & {
	__maxstackApiKeysAuthReady?: boolean
}

/** The `ApiKeyService` bound to this backend — task 57's bearer-token path.
 * Independent DDL-ready flag from `api-keys.server.ts`'s (avoids a circular
 * import; both guard the same `IF NOT EXISTS` table, which is harmless twice). */
async function getApiKeyServiceForAuth(): Promise<ApiKeyService> {
	const { backend } = await getSprout()
	if (!apiKeyAuthScope.__maxstackApiKeysAuthReady) {
		await backend.exec(API_KEYS_LIVE_DDL)
		apiKeyAuthScope.__maxstackApiKeysAuthReady = true
	}
	return new ApiKeyService({ db: backend.db })
}

/**
 * Resolve the request's user. A presented `Authorization: Bearer <key>` wins
 * outright — valid resolves to an api-key identity carrying `apiKeyScope`
 * (the REST routes gate on it, task 57); invalid resolves to `null` straight
 * away, never falling through to the dev fallback (a bad credential must not
 * silently degrade into `dev-admin`). Otherwise a real better-auth session
 * wins; without one, the dev fallback honors an `x-maxstack-role` header
 * (unit tests / quick RBAC demos) and otherwise defaults to the local admin
 * so the admin UI is usable out of the box. `MAXSTACK_AUTH_STRICT=1` disables
 * the fallback entirely — an anonymous request then resolves to `null`
 * (production posture).
 */
export async function resolveUser(
	request: Request,
): Promise<SproutUser | null> {
	const authHeader = request.headers.get('authorization')
	if (authHeader?.startsWith('Bearer ')) {
		const token = authHeader.slice('Bearer '.length).trim()
		const service = await getApiKeyServiceForAuth()
		const result = await service.verifyKey(token)
		if (!result) return null
		return { id: result.userId, role: 'api-key', apiKeyScope: result.scope }
	}
	const auth = await getAuth()
	const sessionUser = await resolveSproutUser(auth, request)
	if (sessionUser) return sessionUser as SproutUser
	if (authStrict()) return null
	// The dev fallbacks below are tagged `devFallback` so the REST write gate can
	// tell them from a real session/api-key and reject anonymous writes once the
	// auth bundle is installed (see `requireWriteAuth`). The admin UI (same-origin,
	// cookie-based) still gets the fallback for reads, so it stays browsable.
	const role = request.headers.get('x-maxstack-role')?.trim()
	if (role === 'admin' || role === 'member')
		return { id: role, role, devFallback: true }
	// Default to admin so the local admin UI is fully usable in dev.
	return { id: 'dev-admin', role: 'admin', devFallback: true }
}

/**
 * A demo spec surface with something to review — the base `newSpecSystem` only
 * carries the product (PRD) layer, so the workbench queue would be empty. We
 * apply a handful of typed spec-ops (exactly as an agent would) to land a few
 * *suggested* data/page/pricing entities plus a pending decision, mixing in one
 * `manual` field so every provenance state shows in the tree. Deterministic
 * (fixed op ids/date) so the seed is reproducible across reloads.
 */
function seedDemoSpec(): SpecSystem {
	let spec = newSpecSystem(tasklyPRD)
	let n = 0
	const meta = () => ({
		id: `op-seed-${++n}` as OpId,
		origin: 'ai' as const,
		appliedAt: '2026-07-09' as const,
	})
	spec = applyOp(
		spec,
		{
			op: 'data.addEntity',
			args: {
				entity: {
					id: 'e-project',
					name: 'Project',
					description: 'A workspace grouping related tasks',
					provenance: suggested({
						suggestedDescription: 'A workspace grouping related tasks',
					}),
					fields: [
						{
							id: 'fld-name',
							name: 'name',
							type: 'string',
							required: true,
							provenance: suggested(),
						},
						{
							id: 'fld-archived',
							name: 'archived',
							type: 'boolean',
							required: false,
							// A field the maintainer added by hand — protected from regen.
							provenance: manual(),
						},
					],
				},
			},
		},
		meta(),
	)
	spec = applyOp(
		spec,
		{
			op: 'page.addPage',
			args: {
				page: {
					id: 'pg-projects',
					name: 'Projects',
					route: '/projects',
					entityId: 'e-project',
					provenance: suggested({ priority: 'high' }),
					blocks: [{ id: 'blk-table', type: 'table', provenance: suggested() }],
				},
			},
		},
		meta(),
	)
	spec = applyOp(
		spec,
		{
			op: 'pricing.addTier',
			args: {
				tier: {
					id: 'tr-team',
					name: 'Team',
					priceMonthly: 12,
					features: ['unlimited projects', 'shared workspaces'],
					provenance: suggested(),
				},
			},
		},
		meta(),
	)
	spec = applyOp(
		spec,
		{
			op: 'prd.recordDecision',
			args: {
				entry: {
					id: 'd-projects-vs-tags',
					question: 'Group tasks by project, or by free-form tags?',
					options: [
						{
							id: 'projects',
							description: 'First-class Project entity',
							pros: ['clear ownership'],
							cons: ['heavier model'],
						},
						{
							id: 'tags',
							description: 'Free-form tags',
							pros: ['flexible'],
							cons: ['no structure'],
						},
					],
					recommendedOptionId: 'projects',
					chosenOptionId: null,
					rationale: '',
					status: 'pending',
					decidedAt: null,
					origin: 'ai',
					recordedAt: '2026-07-09',
				},
			},
		},
		meta(),
	)
	return spec
}

// The platform-tools context (spec ops / generators / checks). A module
// singleton so applied ops persist across requests, mirroring the Sprout store.
// With a data dir (the default in dev — see resolveDataDir) the spec is a
// durable JSON document on disk, seeded from the Taskly fixture on first boot;
// point MAXSTACK_DATA_DIR at a project (e.g. the technews demo output) to open
// the workbench over that project's own spec. In-memory only under unit tests.
const platformScope = globalThis as typeof globalThis & {
	__maxstackPlatform?: PlatformContext
}

function buildSpecStore(): SpecStore {
	const specDir = resolveSpecPath()
	if (!specDir) return createInMemorySpecStore(seedDemoSpec())
	return createFileSpecStore(specDir, { seed: seedDemoSpec })
}

export function getPlatform(): PlatformContext {
	platformScope.__maxstackPlatform ??= {
		spec: buildSpecStore(),
		generators: defaultGeneratorRunner(),
		checks: defaultCheckRunner(),
		origin: 'ai',
		now: () => new Date().toISOString().slice(0, 10),
		nextOpId: () => `op-${crypto.randomUUID()}` as OpId,
	}
	return platformScope.__maxstackPlatform
}

// The audit sink is a process-lifetime singleton so a record's history survives
// spec re-grounding and dev HMR (which rebuild the store). In-memory is enough
// for the demo/admin; a real deployment swaps in `createDrizzleAuditSink` over
// the `audit_log` table (the `audit` bundle) without touching the ops or routes.
const auditScope = globalThis as typeof globalThis & {
	__maxstackAudit?: AuditSink & { query: AuditReader }
	__maxstackWebhooks?: Promise<WebhookService>
	__maxstackJobs?: Promise<JobQueue>
}

/** Idempotent DDL for the `job` table (mirrors `WEBHOOKS_LIVE_DDL` below —
 * `IF NOT EXISTS`-guarded for the running app's live db). */
const JOBS_LIVE_DDL = `
CREATE TABLE IF NOT EXISTS job (
  id text PRIMARY KEY,
  type text NOT NULL,
  payload jsonb NOT NULL,
  status text NOT NULL,
  attempts integer NOT NULL DEFAULT 0,
  max_attempts integer NOT NULL DEFAULT 3,
  result jsonb,
  error text,
  available_at timestamp NOT NULL DEFAULT now(),
  created_at timestamp NOT NULL DEFAULT now(),
  updated_at timestamp NOT NULL DEFAULT now()
);
`

/**
 * Task 59's queue: a process-lifetime singleton (same HMR-survival reasoning
 * as `getAuditSink`'s memory sink) over the persisted `job` table, with the
 * poll worker (`start()`) running once per process. Two job types are wired
 * here — `webhook.emit` (moves task 58's delivery off the mutation's request
 * path) and `export.csv` (a server-side bulk export) — both registered
 * lazily on first construction so a fresh HMR reload re-registers handlers
 * without losing already-persisted rows.
 */
export function getJobQueue(): Promise<JobQueue> {
	auditScope.__maxstackJobs ??= (async () => {
		const { backend, registry, store } = await getSprout()
		await backend.exec(JOBS_LIVE_DDL)
		const queue = new JobQueue({ store: createDrizzleJobStore(backend.db) })

		queue.register<WebhookEvent>('webhook.emit', async (event) => {
			const webhooks = await webhookServiceForAudit()
			await webhooks.emit(event)
		})

		queue.register<{ resource: string; limit?: number }>(
			'export.csv',
			async (input) => {
				const { resourceToCsv } = await import('@maxstack/ui')
				const entry = registry.get(input.resource)
				if (!entry) throw new Error(`Unknown resource "${input.resource}"`)
				const rows = await store.list(input.resource, {
					limit: input.limit ?? 10_000,
				})
				return {
					resource: input.resource,
					rowCount: rows.length,
					csv: resourceToCsv(entry.resource, rows),
				}
			},
		)

		// Retention purge (issue #59): hard-deletes rows a `softDelete: true`
		// resource soft-deleted more than 30 days ago (`comment` in the demo
		// registry, see `buildRegistry` above) — the other half of "recoverable
		// within a window". Runs on the same queue/process-lifetime schedule as
		// the other background jobs.
		schedulePurgeJob(queue, {
			registry,
			store,
			intervalMs: 24 * 60 * 60 * 1000,
		})

		queue.start()
		return queue
	})()
	return auditScope.__maxstackJobs
}

/** Idempotent DDL for the webhook tables (mirrors `webhooks.server.ts`'s local
 * copy — `IF NOT EXISTS`-guarded, independent DDL-ready flag, no circular import). */
const WEBHOOKS_LIVE_DDL = `
CREATE TABLE IF NOT EXISTS webhook_subscription (
  id text PRIMARY KEY,
  user_id text NOT NULL,
  url text NOT NULL,
  secret text NOT NULL,
  events jsonb NOT NULL,
  active boolean NOT NULL DEFAULT true,
  created_at timestamp NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS webhook_delivery (
  id text PRIMARY KEY,
  subscription_id text NOT NULL,
  event_type text NOT NULL,
  payload jsonb NOT NULL,
  status text NOT NULL,
  attempts integer NOT NULL,
  response_status integer,
  error text,
  created_at timestamp NOT NULL DEFAULT now()
);
`

function webhookServiceForAudit(): Promise<WebhookService> {
	auditScope.__maxstackWebhooks ??= (async () => {
		const { backend } = await getSprout()
		await backend.exec(WEBHOOKS_LIVE_DDL)
		return new WebhookService({ db: backend.db })
	})()
	return auditScope.__maxstackWebhooks
}

/**
 * Task 58's event bus: every audit entry — from the generic `/api/:resource`
 * + admin-UI mutation path (`operations.ts`'s `record()`) and from
 * `MemberService`'s injected sink alike, since both share this singleton —
 * also fans out to `WebhookService.emit` as `${resource}.${action}`. The base
 * sink's behavior (history read via `.query`) is unchanged; webhook emission
 * is additive and its failures are swallowed here on top of `emit`'s own
 * internal catch — a subscriber must never be able to break app mutations.
 *
 * Task 59 moves the actual delivery off this request path: rather than
 * `await`ing `WebhookService.emit` inline, the audit sink only *enqueues* a
 * `webhook.emit` job — the mutation's response returns immediately, and the
 * queue's poll worker (`getJobQueue`) delivers (and retries) it out of band.
 * Delivery status is then visible on the `/jobs` page instead of only in a
 * swallowed catch.
 */
export function getAuditSink(): AuditSink & { query: AuditReader } {
	auditScope.__maxstackAudit ??= createMemoryAuditSink()
	const base = auditScope.__maxstackAudit
	const wrapped = async (entry: Parameters<AuditSink>[0]) => {
		await base(entry)
		try {
			const jobs = await getJobQueue()
			await jobs.enqueue({
				type: 'webhook.emit',
				payload: {
					type: `${entry.resource}.${entry.action}`,
					resource: entry.resource,
					resourceId: entry.resourceId,
					data: entry.metadata,
				} satisfies WebhookEvent,
			})
		} catch {
			// Enqueueing is observational from the mutation's point of view — a
			// bus/dispatch failure must never surface to the caller.
		}
	}
	return Object.assign(wrapped, { query: base.query })
}

/** Name of the cookie the org switcher sets. Value = the active org's id. */
export const ORG_COOKIE = 'maxstack-org'

function orgCookieOf(request: Request): string | undefined {
	const cookie = request.headers.get('cookie') ?? ''
	const match = cookie.match(/(?:^|;\s*)maxstack-org=([^;]+)/)
	return match?.[1] ? decodeURIComponent(match[1]) : undefined
}

/**
 * Resolve the request's active org (task 51, d-tenancy-model). The org-switcher
 * cookie is a *claim*; when the project has a `member` resource (the members
 * bundle), the claim is verified against membership and a non-member's claim
 * resolves to no org — which the ops layer then denies for tenant-scoped
 * resources. Without a member resource there is nothing to verify against, so
 * the claim is honored (dev parity with the `x-maxstack-role` fallback).
 */
async function resolveActiveOrg(
	request: Request,
	user: SproutUser,
	registry: ResourceRegistry,
	store: SproutStore,
): Promise<string | undefined> {
	const claimed = orgCookieOf(request)
	if (!claimed) return undefined
	if (!registry.has('member')) return claimed
	const memberships = await store.list('member', {
		filter: { userId: user.id, organizationId: claimed },
		limit: 1,
	})
	return memberships.length > 0 ? claimed : undefined
}

export async function getContext(request: Request): Promise<McpContext> {
	const { registry, store } = await getSprout()
	const user = await resolveUser(request)
	if (user) {
		user.orgId = await resolveActiveOrg(request, user, registry, store)
	}
	return {
		registry,
		store,
		user,
		platform: getPlatform(),
		// Records create/update/delete so per-record history has something to read.
		audit: getAuditSink(),
	}
}

/** `GET`→`read`, `POST`→`create`, `PUT`/`PATCH`→`update`, `DELETE`→`delete` —
 * the REST verb→`SproutAction` mapping the api-key scope gate checks against. */
export function restAction(
	method: string,
): 'read' | 'create' | 'update' | 'delete' | null {
	switch (method) {
		case 'GET':
			return 'read'
		case 'POST':
			return 'create'
		case 'PUT':
		case 'PATCH':
			return 'update'
		case 'DELETE':
			return 'delete'
		default:
			return null
	}
}

/**
 * Task 57: an api-key-authenticated request (`ctx.user.apiKeyScope` set) may
 * only perform actions its scope grants for `resource` — narrower than, never
 * wider than, the resource's own `ResourceAccess` rule (`authorize()` still
 * runs downstream in `operations.ts` exactly as for a session). Session
 * requests (`apiKeyScope` undefined) are unaffected. Returns a 403 `Response`
 * to short-circuit with, or `null` when the call may proceed.
 */
export function checkApiKeyScope(
	ctx: McpContext,
	resource: string,
	method: string,
): Response | null {
	const scope = ctx.user?.apiKeyScope as Record<string, string[]> | undefined
	if (!scope) return null
	const action = restAction(method)
	if (action && scope[resource]?.includes(action)) return null
	return Response.json({ error: 'Out of scope' }, { status: 403 })
}

/**
 * Reject an anonymous **write** to the REST API once the auth bundle is
 * installed. Without this the dev fallback (`resolveUser` → `dev-admin`) makes
 * every external `POST`/`PUT`/`PATCH`/`DELETE` run as admin, so installing auth
 * did not actually protect writes on the API surface (retested: POST 201 /
 * DELETE 200). Reads are untouched (the admin UI stays browsable), and a real
 * session or api-key passes straight through — `authorize()` still runs
 * downstream, so this only closes the anonymous hole, it doesn't replace RBAC.
 *
 * Returns a 401 `Response` to short-circuit with, or `null` to proceed.
 */
export async function requireWriteAuth(
	ctx: McpContext,
	method: string,
): Promise<Response | null> {
	const { authInstalled } = await getSprout()
	if (!isAnonymousWrite(method, authInstalled, ctx.user)) return null
	return Response.json({ error: 'Authentication required' }, { status: 401 })
}

/**
 * The pure decision behind {@link requireWriteAuth}: is this a *write* that
 * should be rejected as anonymous? True only when the method mutates, the auth
 * bundle is installed, and the caller is not a real principal (no user, or a
 * `devFallback`-tagged dev identity). A real session or api-key user passes.
 */
export function isAnonymousWrite(
	method: string,
	authInstalled: boolean,
	user: SproutUser | null,
): boolean {
	const action = restAction(method)
	if (action === null || action === 'read') return false
	if (!authInstalled) return false
	return !user || user.devFallback === true
}

/**
 * The current session's per-action capabilities for a resource — the UI's read
 * of what it may offer, computed with the same rules the server enforces (task
 * 22 / 35). List-level (row-less), so an `owner` rule reads as denied here and
 * the affordance is re-checked per row on the server.
 */
export async function resolveCapabilities(
	ctx: McpContext,
	resource: string,
): Promise<ResourceCapabilities> {
	const entry = ctx.registry.get(resource)
	if (!entry) {
		return { read: false, create: false, update: false, delete: false }
	}
	return resourceCapabilities(
		entry.config.access,
		createAccessContext(ctx.user),
	)
}

/**
 * Batch-resolve every FK in `rows` to a display string, so `<ResourceList>` /
 * `<Show>` render the referenced record's title instead of a raw id (Plan v5
 * task 32). One `getMany` per referenced table; the display column falls back to
 * that table's registered `titleField` when the FK didn't carry one. Returns a
 * serializable map the loader hands to the `references` prop.
 */
export async function resolveRowReferences(
	ctx: McpContext,
	introspection: SproutResource,
	rows: readonly Row[],
): Promise<ReferenceMap> {
	return resolveReferences(introspection, rows, {
		getMany: async (table, ids) => {
			try {
				return await opGetMany(ctx, table, ids)
			} catch {
				// An unreadable referenced table — e.g. `user` (read: authenticated)
				// under an anonymous session — resolves to nothing, so the cell falls
				// back to the raw id instead of failing the whole page. Mirrors
				// `referenceFieldOptions`' posture.
				return []
			}
		},
		displayFieldFor: (table) => ctx.registry.get(table)?.config.titleField,
	})
}

/** A `{ label, value }` picker choice — the referenced record's title + id. */
export interface ReferenceChoice {
	label: string
	value: string
}

/**
 * For every FK column of `introspection`, list the referenced table into
 * `{ label: <title>, value: <id> }` choices — the option set the form's FK
 * autocomplete (`<AutocompleteInput>`) picks from (Plan v5 task 32). Keyed by
 * the FK column name so a route can drop them straight into `uiOptions`.
 */
export async function referenceFieldOptions(
	ctx: McpContext,
	introspection: SproutResource,
): Promise<Record<string, ReferenceChoice[]>> {
	const out: Record<string, ReferenceChoice[]> = {}
	for (const col of introspection.columns) {
		// Single FK or the "many" side (an array reference, task 38) — both pick
		// from the same option set (the referenced table's records).
		const ref = col.references ?? col.meta?.arrayReference
		if (!ref) continue
		const display =
			ref.displayField ?? ctx.registry.get(ref.table)?.config.titleField
		try {
			const rows = await opList(ctx, ref.table, { limit: 100 })
			out[col.name] = rows.map((r) => ({
				value: String(r[ref.column]),
				label:
					display && r[display] != null
						? String(r[display])
						: String(r[ref.column]),
			}))
		} catch {
			// An unreadable/unknown referenced table just yields no options rather
			// than failing the whole page.
			out[col.name] = []
		}
	}
	return out
}

/** A counted reverse relation — a child resource, the noun to label it, and how
 * many rows point back at the current record (Plan v5 task 38). */
export interface ReverseCount {
	resource: string
	label: string
	fk: string
	count: number
}

/**
 * For a record of `resource`, count each *child* resource that has an FK pointing
 * back at it — "12 comments", "3 posts" — without loading the children
 * (`opCount`, the count endpoint task 38 added). The reverse of the FK graph
 * `resolveReferences` walks forward: scans the registry for columns referencing
 * `resource` and counts rows filtered to this id. Powers `<ReferenceManyCount>`.
 */
export async function reverseReferenceCounts(
	ctx: McpContext,
	resource: string,
	id: string,
): Promise<ReverseCount[]> {
	const out: ReverseCount[] = []
	for (const entry of ctx.registry.all()) {
		if (entry.resource.name === resource) continue
		for (const col of entry.resource.columns) {
			if (col.references?.table !== resource) continue
			try {
				const count = await opCount(ctx, entry.resource.name, {
					filter: { [col.name]: id },
				})
				out.push({
					resource: entry.resource.name,
					label: entry.label,
					fk: col.name,
					count,
				})
			} catch {
				// An unreadable child resource is simply omitted, not fatal.
			}
		}
	}
	return out
}
