/* eslint-disable @typescript-eslint/consistent-type-imports */
/* eslint-disable @typescript-eslint/no-require-imports */
import { ensureUrlPathSuffix } from '@n8n/ai-utilities/model-discovery';
import type { EmbeddingModel, LanguageModel } from 'ai';
import type * as Undici from 'undici';

import {
	PROVIDER_CREDENTIAL_SCHEMAS,
	type ProviderId,
	type ProviderCredentials,
} from './provider-credentials';
import type { ModelConfig } from '../../types/sdk/agent';

/**
 * A `fetch`-compatible function. Callers may inject a proxy-aware `fetch` so
 * model calls route through the configured HTTP(S)_PROXY.
 */
export type FetchFn = typeof globalThis.fetch;
export type EmbeddingProviderOptions = {
	apiKey?: string;
	baseURL?: string;
	fetch?: FetchFn;
} & Partial<ProviderCredentials<'aws-bedrock'>>;
type CreateEmbeddingProviderFn = (opts?: EmbeddingProviderOptions) => {
	embeddingModel(model: string): EmbeddingModel;
};

function isLanguageModel(config: unknown): config is LanguageModel {
	return typeof config === 'object' && config !== null && 'doGenerate' in config;
}

/**
 * Env-proxy `fetch` fallback for standalone SDK use.
 *
 * `@n8n/agents` is a standalone SDK and deliberately does not depend on `@n8n/backend-network`,
 * so it cannot build the backend's centrally-guarded transport itself.
 * Inside the n8n backend that guarded `fetch` is always injected into {@link createModel} / {@link createEmbeddingModel}
 * (see cli's `createAiProxyFetch`, which wraps `@n8n/backend-network`), and this fallback is never reached.
 */
function getProxyFetch(): FetchFn | undefined {
	const proxyUrl = process.env.HTTPS_PROXY ?? process.env.HTTP_PROXY;
	if (!proxyUrl) return undefined;

	// eslint-disable-next-line n8n-local-rules/no-uncentralized-http -- standalone SDK cannot depend on @n8n/backend-network; the backend always injects its guarded transport, so this env-proxy path runs only outside the backend (see doc comment above). To drop this: make `fetch` a required arg of createModel/createEmbeddingModel and delete the fallback, so standalone callers always supply their own transport
	const { ProxyAgent } = require('undici') as typeof Undici;
	const dispatcher = new ProxyAgent(proxyUrl);
	return (async (url, init) =>
		await globalThis.fetch(url, {
			...init,
			// @ts-expect-error dispatcher is a valid undici option for Node.js fetch
			dispatcher,
		})) as FetchFn;
}

type EntryBuilder<P extends ProviderId> = (
	creds: ProviderCredentials<P>,
	modelName: string,
	fetch: FetchFn | undefined,
) => LanguageModel;

type RegistryEntry<P extends ProviderId = ProviderId> = {
	build: EntryBuilder<P>;
};

type ProviderRegistry = {
	[P in ProviderId]: RegistryEntry<P>;
};

type OpenAiCompatibleCreds = {
	apiKey?: string;
	baseURL?: string;
	headers?: Record<string, string>;
};

/**
 * Parse a Vertex service-account JSON string into `googleAuthOptions`.
 * Accepts either a full SA JSON blob or the subset `{ client_email, private_key }`.
 * Returns undefined when unset so ADC can take over.
 */
function parseGoogleVertexAuthOptions(
	googleCredentials: string | undefined,
): { credentials: Record<string, unknown> } | undefined {
	if (!googleCredentials?.trim()) return undefined;
	let parsed: unknown;
	try {
		parsed = JSON.parse(googleCredentials);
	} catch {
		throw new Error(
			'Invalid credentials for provider "google-vertex-anthropic": googleCredentials must be valid JSON',
		);
	}
	if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
		throw new Error(
			'Invalid credentials for provider "google-vertex-anthropic": googleCredentials must be a JSON object',
		);
	}
	const credentials = { ...(parsed as Record<string, unknown>) };
	// SA keys often arrive with literal `\n` escapes when pasted into env files.
	if (typeof credentials.private_key === 'string') {
		credentials.private_key = credentials.private_key.replace(/\\n/g, '\n');
	}
	return { credentials };
}

/**
 * Shared builder for OpenAI-compatible HTTP providers. Prefer this over
 * `@ai-sdk/<provider>` packages that pull optional NAPI binaries or v4-only types.
 */
function buildOpenAiCompatible(
	name: string,
	defaultBaseURL: string | undefined,
	creds: OpenAiCompatibleCreds,
	model: string,
	fetch: FetchFn | undefined,
	options?: { includeUsage?: boolean; supportsStructuredOutputs?: boolean },
): LanguageModel {
	const { createOpenAICompatible } =
		require('@ai-sdk/openai-compatible') as typeof import('@ai-sdk/openai-compatible');
	const baseURL = creds.baseURL ?? defaultBaseURL;
	if (!baseURL) {
		throw new Error(`baseURL is required for OpenAI-compatible provider "${name}"`);
	}
	return createOpenAICompatible({
		name,
		baseURL,
		apiKey: creds.apiKey,
		headers: creds.headers,
		fetch,
		includeUsage: options?.includeUsage,
		supportsStructuredOutputs: options?.supportsStructuredOutputs,
	})(model);
}

type OpenAiCompatibleProviderId = 'nvidia';

function isOfficialOpenAiBaseUrl(baseURL: string | undefined): boolean {
	return baseURL?.replace(/\/+$/, '') === 'https://api.openai.com/v1';
}

function openAiCompatibleEntry<P extends OpenAiCompatibleProviderId>(
	name: P,
	defaultBaseURL: string,
	options?: { includeUsage?: boolean; supportsStructuredOutputs?: boolean },
): RegistryEntry<P> {
	return {
		build: (creds, model, fetch) =>
			buildOpenAiCompatible(name, defaultBaseURL, creds, model, fetch, options),
	};
}

/**
 * Registry of language model providers.
 * Each entry maps a provider id to a builder that loads its @ai-sdk/* package
 * and instantiates the model. Credentials are Zod-validated before being passed in.
 */
const LANGUAGE_PROVIDERS: ProviderRegistry = {
	openai: {
		build: (creds, model, fetch) => {
			const { createOpenAI } = require('@ai-sdk/openai') as typeof import('@ai-sdk/openai');
			const { apiStyle, ...providerCreds } = creds;
			const provider = createOpenAI({ ...providerCreds, fetch });
			// A custom baseURL usually means an OpenAI-COMPATIBLE server (LM Studio,
			// vLLM, Ollama), which speaks /chat/completions; the provider's default
			// model targets OpenAI's own Responses API (/responses) that those
			// servers do not implement. OpenAI credentials also carry the official
			// baseURL, so keep those on /responses. `apiStyle` handles proxies that
			// explicitly support one API or the other.
			const useChat =
				apiStyle === 'chat' ||
				(apiStyle === undefined &&
					Boolean(providerCreds.baseURL && !isOfficialOpenAiBaseUrl(providerCreds.baseURL)));
			return useChat ? provider.chat(model) : provider(model);
		},
	},
	custom: {
		build: (creds, model, fetch) =>
			buildOpenAiCompatible('custom', undefined, creds, model, fetch, {
				supportsStructuredOutputs: creds.supportsStructuredOutputs,
			}),
	},
	anthropic: {
		build: (creds, model, fetch) => {
			const { createAnthropic } =
				require('@ai-sdk/anthropic') as typeof import('@ai-sdk/anthropic');
			let normalizedBaseURL = creds.baseURL;
			// The SDK expects the versioned base (default `https://api.anthropic.com/v1`),
			// but n8n Anthropic credentials store the host without `/v1` — their
			// consumers append the version segment themselves.
			if (normalizedBaseURL) {
				const url = new URL(normalizedBaseURL);
				if (!url.pathname.replace(/\/$/, '').endsWith('/v1')) {
					url.pathname = url.pathname.replace(/\/?$/, '/v1');
					normalizedBaseURL = url.toString();
				}
			}
			return createAnthropic({ ...creds, baseURL: normalizedBaseURL, fetch })(model);
		},
	},
	'google-vertex-anthropic': {
		build: (creds, model, fetch) => {
			const { createVertexAnthropic } =
				require('@ai-sdk/google-vertex/anthropic') as typeof import('@ai-sdk/google-vertex/anthropic');
			const googleAuthOptions = parseGoogleVertexAuthOptions(creds.googleCredentials);
			return createVertexAnthropic({
				project: creds.project,
				location: creds.location,
				baseURL: creds.baseURL,
				headers: creds.headers,
				...(googleAuthOptions ? { googleAuthOptions } : {}),
				fetch,
			})(model);
		},
	},
	google: {
		build: (creds, model, fetch) => {
			const { createGoogle } = require('@ai-sdk/google') as typeof import('@ai-sdk/google');
			// The SDK expects a version-qualified base (its own default ends in
			// `/v1beta`), but `googlePalmApi.host` stores the bare host — the Gemini
			// node's SDK appends the API version itself. Passing the host through
			// unqualified drops the version from every request path, and Google
			// answers 404 for any model.
			const normalizedBaseURL = creds.baseURL
				? ensureUrlPathSuffix(creds.baseURL, '/v1beta')
				: creds.baseURL;
			return createGoogle({ ...creds, baseURL: normalizedBaseURL, fetch })(model);
		},
	},
	xai: {
		build: (creds, model, fetch) => {
			const { createXai } = require('@ai-sdk/xai') as typeof import('@ai-sdk/xai');
			return createXai({ ...creds, fetch }).chat(model);
		},
	},
	groq: {
		build: (creds, model, fetch) => {
			const { createGroq } = require('@ai-sdk/groq') as typeof import('@ai-sdk/groq');
			return createGroq({ ...creds, fetch })(model);
		},
	},
	deepseek: {
		build: (creds, model, fetch) => {
			const { createDeepSeek } = require('@ai-sdk/deepseek') as typeof import('@ai-sdk/deepseek');
			return createDeepSeek({ ...creds, fetch })(model);
		},
	},
	cohere: {
		build: (creds, model, fetch) => {
			const { createCohere } = require('@ai-sdk/cohere') as typeof import('@ai-sdk/cohere');
			return createCohere({ ...creds, fetch })(model);
		},
	},
	mistral: {
		build: (creds, model, fetch) => {
			const { createMistral } = require('@ai-sdk/mistral') as typeof import('@ai-sdk/mistral');
			return createMistral({ ...creds, fetch })(model);
		},
	},
	vercel: {
		build: (creds, model, fetch) => {
			const { createGateway } = require('@ai-sdk/gateway') as typeof import('@ai-sdk/gateway');
			return createGateway({ ...creds, fetch })(model);
		},
	},
	openrouter: {
		build: (creds, model, fetch) => {
			const { createOpenRouter } =
				require('@openrouter/ai-sdk-provider') as typeof import('@openrouter/ai-sdk-provider');
			return createOpenRouter({ apiKey: creds.apiKey, baseURL: creds.baseURL, fetch })(model);
		},
	},
	nvidia: openAiCompatibleEntry('nvidia', 'https://integrate.api.nvidia.com/v1', {}),
	moonshotai: {
		build: (creds, model, fetch) => {
			const { createMoonshotAI } =
				require('@ai-sdk/moonshotai') as typeof import('@ai-sdk/moonshotai');
			return createMoonshotAI({ ...creds, fetch })(model);
		},
	},
	alibaba: {
		build: (creds, model, fetch) => {
			const { createAlibaba } = require('@ai-sdk/alibaba') as typeof import('@ai-sdk/alibaba');
			// The SDK expects the OpenAI-compatible base, but n8n Alibaba credentials
			// store the region's bare host — Alibaba serves its native and its
			// OpenAI-compatible API under different paths on that host.
			const normalizedBaseURL = creds.baseURL
				? ensureUrlPathSuffix(creds.baseURL, '/compatible-mode/v1')
				: creds.baseURL;
			return createAlibaba({ ...creds, baseURL: normalizedBaseURL, fetch })(model);
		},
	},
	minimax: {
		build: (creds, model, fetch) => {
			const { createMiniMax } = require('@ai-sdk/minimax') as typeof import('@ai-sdk/minimax');
			// The SDK speaks MiniMax's Anthropic-compatible API, which MiniMax also
			// recommends, but n8n MiniMax credentials store the OpenAI-compatible base.
			const normalizedBaseURL = creds.baseURL
				? ensureUrlPathSuffix(creds.baseURL, '/anthropic/v1', { stripSuffix: '/v1' })
				: creds.baseURL;
			return createMiniMax({ ...creds, baseURL: normalizedBaseURL, fetch })(model);
		},
	},
	'azure-openai': {
		build: (creds, model, fetch) => {
			const { baseURL, resourceName, apiVersion, apiKey, endpointType, deploymentName } = creds;

			// Azure AI Foundry exposes an OpenAI-compatible `/openai/v1` base on
			// `*.services.ai.azure.com`. `@ai-sdk/azure`'s URL builder assumes the
			// classic `*.openai.azure.com` shape (it appends `/openai` and injects
			// `/deployments/{id}`), which mangles the Foundry URL into
			// `…/openai/v1/openai`. Drive it as a plain OpenAI-compatible endpoint
			// so the configured base is used verbatim.
			if (endpointType === 'foundry') {
				return buildOpenAiCompatible('azure-openai', undefined, { apiKey, baseURL }, model, fetch);
			}

			// Classic Azure OpenAI (`*.openai.azure.com`, or `resourceName` only).
			// Use chat completions over deployment-based URLs so the credential's
			// date-based `apiVersion` (e.g. `2025-03-01-preview`) matches the URL
			// scheme Azure expects — mirroring the LangChain Azure node, which
			// forces `useResponsesApi: false`. The SDK's default `provider(model)`
			// selects the Responses API + the `/v1/` path, which Azure rejects with
			// "API version not supported" for date-based versions.
			//
			// Azure deployments are user-named and surfaced in the deployment-based
			// URL path. The catalog model id is not the deployment id, so prefer the
			// user's `deploymentName` when provided and fall back to the model id.
			const { createAzure } = require('@ai-sdk/azure') as typeof import('@ai-sdk/azure');
			let normalizedBaseURL = baseURL;
			// SDK expects url like `https://resourceName.openai.azure.com/openai`
			if (normalizedBaseURL) {
				const url = new URL(normalizedBaseURL);
				if (!url.pathname.endsWith('/openai')) {
					url.pathname = url.pathname.replace(/\/?$/, '/openai');
					normalizedBaseURL = url.toString();
				}
			}
			return createAzure({
				resourceName,
				apiKey,
				baseURL: normalizedBaseURL,
				apiVersion,
				useDeploymentBasedUrls: true,
				fetch,
			}).chat(deploymentName ?? model);
		},
	},
	'aws-bedrock': {
		build: (creds, model, fetch) => {
			const { createAmazonBedrock } =
				require('@ai-sdk/amazon-bedrock') as typeof import('@ai-sdk/amazon-bedrock');
			return createAmazonBedrock({
				region: creds.region,
				accessKeyId: creds.accessKeyId,
				secretAccessKey: creds.secretAccessKey,
				sessionToken: creds.sessionToken,
				fetch,
			})(model);
		},
	},
};

const SUPPORTED_PROVIDERS = Object.keys(LANGUAGE_PROVIDERS).join(', ');

/**
 * Provider packages are loaded dynamically via require() so only the
 * provider needed at runtime must be installed.
 */
export function createModel(config: ModelConfig, fetch?: FetchFn): LanguageModel {
	if (isLanguageModel(config)) {
		return config;
	}

	const rawId = typeof config === 'string' ? config : config.id;
	if (!rawId || rawId.trim() === '') {
		throw new Error('Model ID is required');
	}

	const slashIndex = rawId.indexOf('/');
	if (slashIndex <= 0) {
		throw new Error(`Invalid model ID "${rawId}": expected "provider/model-name" format`);
	}
	const provider = rawId.slice(0, slashIndex) as ProviderId;
	const modelName = rawId.slice(slashIndex + 1);

	const entry = LANGUAGE_PROVIDERS[provider];
	if (!entry) {
		throw new Error(
			`Unsupported provider: "${provider}". Supported providers: ${SUPPORTED_PROVIDERS}`,
		);
	}

	// Collect credential fields: strip `id`, pass the rest to Zod validation.
	let credFields: Record<string, unknown> = {};
	if (typeof config !== 'string') {
		const { id: _id, ...rest } = config as { id: string; [k: string]: unknown };
		credFields = rest;
	}
	// Host configs (e.g. Instance AI's `{ id, url }` for OpenAI-compatible
	// endpoints) spell the base URL as `url`; the provider schemas only know
	// `baseURL`, and Zod strips unknown keys, so normalize before validation.
	// An EMPTY url means "no custom endpoint" (Instance AI emits `url: ''` for
	// the api-key-only config) and must keep the provider default.
	if (typeof credFields.url === 'string' && credFields.baseURL === undefined) {
		const { url, ...restCreds } = credFields;
		credFields = url ? { ...restCreds, baseURL: url } : restCreds;
	}

	const schema = PROVIDER_CREDENTIAL_SCHEMAS[provider];
	const parsed = schema.safeParse(credFields);
	if (!parsed.success) {
		const issues = parsed.error.issues
			.map((i) => `  - ${i.path.join('.')}: ${i.message}`)
			.join('\n');
		throw new Error(`Invalid credentials for provider "${provider}":\n${issues}`);
	}

	// Caller-injected transport wins; fall back to the ambient env-proxy resolver.
	const resolvedFetch = fetch ?? getProxyFetch();
	// Type cast: the registry guarantees the schema and builder are aligned per provider.
	return (entry.build as EntryBuilder<typeof provider>)(
		parsed.data as never,
		modelName,
		resolvedFetch,
	);
}

/**
 * Registry of embedding provider packages and their factory function names.
 * Each AI SDK provider follows the same pattern:
 *   createProvider({ apiKey }).embeddingModel(modelName)
 *
 * To add a new provider, install its @ai-sdk/* package and add an entry here.
 */
const EMBEDDING_PROVIDERS = {
	openai: { pkg: '@ai-sdk/openai', factory: 'createOpenAI' },
	google: { pkg: '@ai-sdk/google', factory: 'createGoogle' },
	mistral: { pkg: '@ai-sdk/mistral', factory: 'createMistral' },
	cohere: { pkg: '@ai-sdk/cohere', factory: 'createCohere' },
	amazon: { pkg: '@ai-sdk/amazon-bedrock', factory: 'createAmazonBedrock' },
	bedrock: { pkg: '@ai-sdk/amazon-bedrock', factory: 'createAmazonBedrock' },
} as const;

type EmbeddingProvider = keyof typeof EMBEDDING_PROVIDERS;
type EmbeddingModelId = `${EmbeddingProvider}/${string}`;

/**
 * Create an embedding model from a "provider/model" string (e.g. "openai/text-embedding-3-small").
 * Supports any AI SDK provider that exposes `.embeddingModel()`.
 * The provider package must be installed at runtime.
 */
export function createEmbeddingModel(
	embedderString: EmbeddingModelId | (string & {}),
	options?: string | EmbeddingProviderOptions,
): EmbeddingModel {
	const [provider, ...rest] = embedderString.split('/');
	const modelName = rest.join('/');

	const entry = EMBEDDING_PROVIDERS[provider as EmbeddingProvider];
	if (!entry) {
		const supported = Object.keys(EMBEDDING_PROVIDERS).join(', ');
		throw new Error(`Unsupported embedding provider: "${provider}". Supported: ${supported}`);
	}

	const mod = require(entry.pkg) as Record<string, CreateEmbeddingProviderFn>;
	const factory = mod[entry.factory];
	const providerOptions = typeof options === 'string' ? { apiKey: options } : options;
	return factory(providerOptions).embeddingModel(modelName);
}
