/**
 * MCP integration test harness.
 *
 * Builds a real `EmDashRuntime` against a pre-migrated test database, wires
 * its handlers into a real `McpServer`, and connects a real `Client` over
 * `InMemoryTransport`. No mocks. Production code paths run end-to-end —
 * MCP tool dispatch, runtime handler logic (incl. draft revision flow),
 * `ApiResult` error envelopes, repositories, and SQL.
 *
 * Use this for any test that asserts behavior of an MCP tool against real
 * data. Use the unit-level mocked-handler suite (`tests/unit/mcp`) only
 * for pure authorization/scope gating where a real DB adds nothing.
 */

import type { RoleLevel } from "@emdash-cms/auth";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import type { Kysely } from "kysely";

import type { EmDashConfig } from "../../src/astro/integration/runtime.js";
import type { EmDashHandlers } from "../../src/astro/types.js";
import type { Database } from "../../src/database/types.js";
import { EmDashRuntime } from "../../src/emdash-runtime.js";
import { createMcpServer } from "../../src/mcp/server.js";
import { createHookPipeline } from "../../src/plugins/hooks.js";
import type { ResolvedPlugin } from "../../src/plugins/types.js";
import { invalidateUrlPatternCache } from "../../src/query.js";

// ---------------------------------------------------------------------------
// Auth-injecting transport
//
// Mirrors the production HTTP transport: every client send carries authInfo
// with the user's role + scopes + emdash handle. The MCP server pulls these
// out of `extra.authInfo.extra` to authorize the request.
// ---------------------------------------------------------------------------

class AuthInjectingTransport extends InMemoryTransport {
	constructor(private authInfo: Record<string, unknown>) {
		super();
	}

	override async send(
		message: Parameters<InMemoryTransport["send"]>[0],
		options?: Parameters<InMemoryTransport["send"]>[1],
	): Promise<void> {
		const existingExtra =
			options?.authInfo && typeof options.authInfo === "object" && "extra" in options.authInfo
				? // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- narrowed by typeof + 'in' check
					(options.authInfo.extra as Record<string, unknown>)
				: {};
		return super.send(message, {
			...options,
			authInfo: {
				token: "",
				clientId: "test",
				scopes: [],
				...options?.authInfo,
				extra: {
					...this.authInfo,
					...existingExtra,
				},
			},
		});
	}
}

function createAuthenticatedPair(authInfo: {
	emdash: EmDashHandlers;
	userId: string;
	userRole: RoleLevel;
	tokenScopes?: string[];
}): [AuthInjectingTransport, InMemoryTransport] {
	const clientTransport = new AuthInjectingTransport(authInfo);
	const serverTransport = new InMemoryTransport();
	// Link them — InMemoryTransport's pairing uses `_otherTransport`.
	(clientTransport as unknown as Record<string, unknown>)._otherTransport = serverTransport;
	(serverTransport as unknown as Record<string, unknown>)._otherTransport = clientTransport;
	return [clientTransport, serverTransport];
}

// ---------------------------------------------------------------------------
// Real runtime construction
//
// Builds a runtime around a pre-migrated DB without spinning up cron,
// marketplace, sandboxed plugins, or a media provider. Every code path the
// MCP tools exercise — content handlers, repositories, schema registry,
// draft revisions, FTS — runs through the real runtime methods.
//
// Note: this constructs `EmDashRuntime` directly via its public constructor.
// The runtime never reads `runtimeDeps` after construction except in
// `rebuildHookPipeline`, which tests do not call. cron/scheduler are null.
// ---------------------------------------------------------------------------

export interface TestRuntimeOptions {
	/** Optional plugins to participate in the hook pipeline. Default: none. */
	plugins?: ResolvedPlugin[];
	/** Optional partial config override. Default: empty config. */
	config?: Partial<EmDashConfig>;
}

/**
 * Build a real `EmDashRuntime` for a test database.
 *
 * The DB must already have migrations + collections set up (use
 * `setupTestDatabaseWithCollections()` or equivalent).
 */
export function createTestRuntime(
	db: Kysely<Database>,
	opts: TestRuntimeOptions = {},
): EmDashRuntime {
	const plugins = opts.plugins ?? [];
	const config: EmDashConfig = { ...opts.config };

	const pipelineFactoryOptions = { db } as const;
	const hooks = createHookPipeline(plugins, pipelineFactoryOptions);
	const pipelineRef = { current: hooks };

	// runtimeDeps is only consumed by `rebuildHookPipeline()`, which is not
	// invoked by any MCP tool path. We pass a minimal stub so the field
	// satisfies the type. If a future test ever touches plugin toggling, this
	// stub will need expanding (and that's a useful failure to hit, not
	// silent dead code).
	const runtimeDeps = {
		config,
		plugins,
		// eslint-disable-next-line typescript-eslint(no-explicit-any) -- match RuntimeDependencies signature
		createDialect: (() => {
			throw new Error("createDialect not available in test runtime");
		}) as any,
		createStorage: null,
		sandboxEnabled: false,
		sandboxedPluginEntries: [],
		createSandboxRunner: null,
	};

	return new EmDashRuntime({
		db,
		storage: null,
		configuredPlugins: plugins,
		sandboxedPlugins: new Map(),
		sandboxedPluginEntries: [],
		hooks,
		enabledPlugins: new Set(plugins.map((p) => p.id)),
		pluginStates: new Map(),
		config,
		mediaProviders: new Map(),
		mediaProviderEntries: [],
		cronExecutor: null,
		cronScheduler: null,
		emailPipeline: null,
		allPipelinePlugins: plugins,
		pipelineFactoryOptions,
		runtimeDeps,
		pipelineRef,
	});
}

/**
 * Build the `EmDashHandlers` shape the MCP server consumes from a runtime.
 *
 * Mirrors the wiring in `astro/middleware.ts` so the same code paths run
 * under test as in production.
 */
export function handlersFromRuntime(runtime: EmDashRuntime): EmDashHandlers {
	const handlers: EmDashHandlers = {
		// Content
		handleContentList: runtime.handleContentList.bind(runtime),
		handleContentGet: runtime.handleContentGet.bind(runtime),
		handleContentGetIncludingTrashed: runtime.handleContentGetIncludingTrashed.bind(runtime),
		handleContentCreate: runtime.handleContentCreate.bind(runtime),
		handleContentUpdate: runtime.handleContentUpdate.bind(runtime),
		handleContentDelete: runtime.handleContentDelete.bind(runtime),
		handleContentDuplicate: runtime.handleContentDuplicate.bind(runtime),
		handleContentRestore: runtime.handleContentRestore.bind(runtime),
		handleContentPermanentDelete: runtime.handleContentPermanentDelete.bind(runtime),
		handleContentListTrashed: runtime.handleContentListTrashed.bind(runtime),
		handleContentCountTrashed: runtime.handleContentCountTrashed.bind(runtime),
		handleContentPublish: runtime.handleContentPublish.bind(runtime),
		handleContentUnpublish: runtime.handleContentUnpublish.bind(runtime),
		handleContentSchedule: runtime.handleContentSchedule.bind(runtime),
		handleContentUnschedule: runtime.handleContentUnschedule.bind(runtime),
		handleContentCountScheduled: runtime.handleContentCountScheduled.bind(runtime),
		handleContentDiscardDraft: runtime.handleContentDiscardDraft.bind(runtime),
		handleContentCompare: runtime.handleContentCompare.bind(runtime),
		handleContentTranslations: runtime.handleContentTranslations.bind(runtime),

		// Media
		handleMediaList: runtime.handleMediaList.bind(runtime),
		handleMediaGet: runtime.handleMediaGet.bind(runtime),
		handleMediaCreate: runtime.handleMediaCreate.bind(runtime),
		handleMediaUpdate: runtime.handleMediaUpdate.bind(runtime),
		handleMediaDelete: runtime.handleMediaDelete.bind(runtime),

		// Revisions
		handleRevisionList: runtime.handleRevisionList.bind(runtime),
		handleRevisionGet: runtime.handleRevisionGet.bind(runtime),
		handleRevisionRestore: runtime.handleRevisionRestore.bind(runtime),

		// Direct access (MCP tools use db for schema/menu/taxonomy/search)
		storage: runtime.storage,
		db: runtime.db,
		hooks: runtime.hooks,
		email: runtime.email,
		configuredPlugins: runtime.configuredPlugins,
		config: runtime.config,
		getManifest: runtime.getManifest.bind(runtime),
		invalidateUrlPatternCache,

		// Fields the MCP server doesn't currently call. Stub so the type
		// checks; if a tool ever reaches for one, the test will throw a
		// clear error rather than silently no-op.
		handlePluginApiRoute: () => {
			throw new Error("handlePluginApiRoute not implemented in test runtime");
		},
		getPluginRouteMeta: () => null,
		getMediaProvider: runtime.getMediaProvider.bind(runtime),
		getMediaProviderList: runtime.getMediaProviderList.bind(runtime),
		getSandboxRunner: runtime.getSandboxRunner.bind(runtime),
		syncMarketplacePlugins: () => Promise.resolve(),
		setPluginStatus: runtime.setPluginStatus.bind(runtime),
		collectPageMetadata: runtime.collectPageMetadata.bind(runtime),
		collectPageFragments: runtime.collectPageFragments.bind(runtime),
		ensureSearchHealthy: runtime.ensureSearchHealthy.bind(runtime),
	};
	return handlers;
}

// ---------------------------------------------------------------------------
// MCP client/server pair
// ---------------------------------------------------------------------------

export interface McpHarness {
	/** The connected MCP client — call `client.callTool({ name, arguments })`. */
	client: Client;
	/** The runtime backing the harness. Use to make direct DB writes/reads in setup. */
	runtime: EmDashRuntime;
	/** The handlers wired into the MCP server. */
	handlers: EmDashHandlers;
	/** Tear down both client and server. */
	cleanup: () => Promise<void>;
}

export interface ConnectMcpOptions {
	db: Kysely<Database>;
	userId: string;
	userRole: RoleLevel;
	tokenScopes?: string[];
	runtimeOptions?: TestRuntimeOptions;
}

/**
 * Connect a real MCP client/server pair against a real runtime + DB.
 *
 * No mocks. The MCP tool dispatch, the runtime handlers, and the database
 * are all production code. Anything that goes wrong in this harness is
 * something users will hit too.
 */
export async function connectMcpHarness(opts: ConnectMcpOptions): Promise<McpHarness> {
	const runtime = createTestRuntime(opts.db, opts.runtimeOptions);
	const handlers = handlersFromRuntime(runtime);

	const server = createMcpServer();
	const [clientTransport, serverTransport] = createAuthenticatedPair({
		emdash: handlers,
		userId: opts.userId,
		userRole: opts.userRole,
		tokenScopes: opts.tokenScopes,
	});

	const client = new Client({ name: "test", version: "1.0" });

	await server.connect(serverTransport);
	await client.connect(clientTransport);

	return {
		client,
		runtime,
		handlers,
		cleanup: async () => {
			await client.close();
			await server.close();
		},
	};
}

// ---------------------------------------------------------------------------
// Result helpers
//
// MCP tool results are an array of `{ type: "text", text: string }` blocks,
// with `isError: true` on failure. Tests almost always need either the
// parsed JSON of the success payload or the raw error text. These helpers
// make both readable.
// ---------------------------------------------------------------------------

interface ToolResult {
	content?: Array<{ type: string; text?: string }>;
	isError?: boolean;
	[key: string]: unknown;
}

/** Extract the first text block's content from a tool result. */
export function extractText(result: unknown): string {
	const r = result as ToolResult;
	const block = r.content?.[0];
	return typeof block?.text === "string" ? block.text : "";
}

/** Parse the JSON success payload of a tool result. Throws if the call errored. */
export function extractJson<T = unknown>(result: unknown): T {
	const r = result as ToolResult;
	if (r.isError) {
		throw new Error(`Expected success but got error: ${extractText(result)}`);
	}
	const text = extractText(result);
	if (!text) {
		throw new Error("Tool result had no text content");
	}
	return JSON.parse(text) as T;
}

/** Whether the result is an MCP error response. */
export function isErrorResult(result: unknown): boolean {
	return (result as ToolResult).isError === true;
}
