/**
 * Deterministic provider-envelope normalizers for the eval HTTP mock.
 *
 * The mock body is generated by an LLM (see `mock-handler.ts`), guided by soft
 * `MOCK_QUIRKS` and the `submit_response` reject/resubmit loop. Those layers cut
 * shape errors but don't eliminate them: for a handful of APIs the n8n node
 * reads one specific path and CRASHES when it's missing (not a silent empty
 * result). This module is the last-resort deterministic backstop — it coerces a
 * shape-wrong LLM payload into the exact envelope the real node consumes,
 * mirroring the model-turn coercion in `openai-responses-envelope.ts`.
 *
 * Each normalizer:
 *   - matches its endpoint by request path (+ hostname/method where needed),
 *   - is a no-op on a payload already in the correct shape,
 *   - mutates only `spec.body`, only for `type: "json"` specs.
 *
 * `findProviderShapeViolation` is the read-only counterpart used by the
 * `submit_response` one-shot conformance nudge — it reports (without fixing)
 * the same shape defects so the model gets a chance to produce full-fidelity
 * data before the normalizer falls back to a minimal envelope.
 */

import { evalCanvasPng } from 'n8n-core';

/** The subset of the mock response spec these normalizers read/mutate. */
interface NormalizableSpec {
	type: 'json' | 'text' | 'binary' | 'error';
	body?: unknown;
}

/** Request identity the normalizers dispatch on (already parsed by the caller). */
export interface ProviderRequestInfo {
	method: string;
	pathname: string;
	hostname?: string;
}

// Generated image bytes must be REAL: workflows routinely pipe them into
// image-processing nodes (Edit Image → GraphicsMagick), which crash on a
// truncated or 1×1 placeholder ("Insufficient image data"). The model cannot
// author valid image bytes, so every b64_json is substituted with a decodable
// drawable canvas.
let canvasPngB64: string | undefined;
function placeholderImageB64(): string {
	canvasPngB64 ??= evalCanvasPng().toString('base64');
	return canvasPngB64;
}

// A deterministic numeric HubSpot contact id used when the model didn't supply
// one — the node interpolates it into a follow-up `/contact/vid/{vid}/profile`
// URL, so it must be a number.
const DEFAULT_HUBSPOT_VID = 3234574;

function isPlainObject(value: unknown): value is Record<string, unknown> {
	return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function nowUnix(): number {
	return Math.floor(Date.now() / 1000);
}

// ---------------------------------------------------------------------------
// Endpoint matchers
// ---------------------------------------------------------------------------

function isOpenAiImagesGeneration(path: string): boolean {
	return path.endsWith('/images/generations');
}

function isOpenAiCompletion(path: string): 'responses' | 'chat' | null {
	// Tolerate trailing slashes and any /v{n}/ — endsWith misses "/v1/responses/".
	if (/\/v\d+\/responses\/?$/.test(path)) return 'responses';
	if (/\/chat\/completions\/?$/.test(path)) return 'chat';
	return null;
}

function isGeminiGenerate(host: string, path: string): boolean {
	return host === 'generativelanguage.googleapis.com' && path.includes('generatecontent');
}

function isRedditWrite(host: string, path: string, method: string): 'submit' | 'comment' | null {
	if (method.toUpperCase() !== 'POST' || !host.includes('reddit.com')) return null;
	if (path.includes('api/comment')) return 'comment';
	if (path.includes('api/submit')) return 'submit';
	return null;
}

function isHubspotUpsert(host: string, path: string): boolean {
	return host.includes('hubapi.com') && path.includes('/contacts/v1/contact/createorupdate');
}

function isGoogleDocsBatchUpdate(host: string, path: string): boolean {
	return (
		host === 'docs.googleapis.com' && path.includes('/documents/') && path.includes(':batchupdate')
	);
}

function isGmailMessagesList(host: string, path: string, method: string): boolean {
	return (
		method.toUpperCase() === 'GET' &&
		host.endsWith('googleapis.com') &&
		/\/gmail\/v1\/users\/[^/]+\/messages\/?$/.test(path)
	);
}

// ---------------------------------------------------------------------------
// Tolerant text extraction (shared by the Gemini normalizer)
// ---------------------------------------------------------------------------

/** Best-effort answer text from a shapeless payload — mirrors the OpenAI Responses extractor. */
function extractAnswerText(body: unknown): string {
	if (body === null || body === undefined) return '';
	if (typeof body === 'string') return body;
	if (!isPlainObject(body)) return String(body as number | boolean | bigint);

	if (typeof body.text === 'string') return body.text;
	if (typeof body.content === 'string') return body.content;
	if (typeof body.output_text === 'string') return body.output_text;
	if (typeof body.message === 'string') return body.message;

	return JSON.stringify(body);
}

// ---------------------------------------------------------------------------
// OpenAI images — /v1/images/generations
// ---------------------------------------------------------------------------

/**
 * The OpenAI image node does `const { data } = response` then, for the default
 * `b64_json` format, `for (const entry of data) Buffer.from(entry.b64_json)`.
 * A missing `data` array ("data is not iterable") or a `url`-only entry
 * ("Received undefined") crashes it. Guarantee a `data` array whose every entry
 * carries a `b64_json` string.
 */
function normalizeOpenAiImages(spec: NormalizableSpec): void {
	const body = spec.body;

	let base: Record<string, unknown> = {};
	let rawEntries: unknown[] | undefined;

	if (isPlainObject(body) && Array.isArray(body.data)) {
		base = body;
		rawEntries = body.data;
	} else if (Array.isArray(body)) {
		rawEntries = body;
	} else if (isPlainObject(body) && ('b64_json' in body || 'url' in body)) {
		rawEntries = [body];
	} else if (isPlainObject(body)) {
		base = body;
	}

	const entries = rawEntries && rawEntries.length > 0 ? rawEntries : [{}];
	const data = entries.map(coerceImageEntry);

	spec.body = { created: nowUnix(), ...base, data };
}

function coerceImageEntry(entry: unknown): Record<string, unknown> {
	// Every entry gets real bytes (the node's default b64_json mode crashes on a
	// missing field even when the model answered url-style); `url` is preserved.
	const base = isPlainObject(entry) ? entry : {};
	return { ...base, b64_json: placeholderImageB64() };
}

// ---------------------------------------------------------------------------
// OpenAI completions — /v1/responses, /chat/completions
// ---------------------------------------------------------------------------

/**
 * The real OpenAI envelopes carry the model's answer as a STRING —
 * `output[].content[].text` on /v1/responses, `choices[].message.content` on
 * chat/completions — even when the answer is JSON (`json_object` format returns
 * a JSON-encoded string). The generator sometimes embeds the parsed object
 * instead; downstream code then runs `JSON.parse` on `"[object Object]"` and
 * yields 0 items. Serialize any non-string payload at those paths; everything
 * else (tool_call items, null content, content-part arrays) is left untouched.
 */
function normalizeOpenAiCompletionText(spec: NormalizableSpec, kind: 'responses' | 'chat'): void {
	const body = spec.body;
	if (!isPlainObject(body)) return;

	if (kind === 'responses') {
		if (Array.isArray(body.output)) {
			for (const item of body.output) {
				if (!isPlainObject(item) || !Array.isArray(item.content)) continue;
				for (const part of item.content) {
					if (isPlainObject(part) && 'text' in part && typeof part.text !== 'string') {
						part.text = JSON.stringify(part.text);
					}
				}
			}
		}
		if (body.output_text !== undefined && typeof body.output_text !== 'string') {
			body.output_text = JSON.stringify(body.output_text);
		}
		return;
	}

	if (Array.isArray(body.choices)) {
		for (const choice of body.choices) {
			if (!isPlainObject(choice) || !isPlainObject(choice.message)) continue;
			const content = choice.message.content;
			// null (tool-calls-only turn) and content-part arrays are valid shapes.
			if (content !== null && content !== undefined && typeof content !== 'string') {
				if (!Array.isArray(content)) choice.message.content = JSON.stringify(content);
			}
		}
	}
}

// ---------------------------------------------------------------------------
// Google Gemini — :generateContent
// ---------------------------------------------------------------------------

/**
 * The n8n Gemini node reads `candidates[].content.parts[].text`. Wrap a bare
 * payload into the full envelope; leave a payload that already has
 * `candidates[0].content.parts` untouched (its parts may be tool calls, not text).
 */
function normalizeGemini(spec: NormalizableSpec): void {
	if (hasGeminiCandidates(spec.body)) return;

	const text = extractAnswerText(spec.body);
	spec.body = {
		candidates: [{ content: { parts: [{ text }], role: 'model' }, finishReason: 'STOP', index: 0 }],
		usageMetadata: { promptTokenCount: 0, candidatesTokenCount: 0, totalTokenCount: 0 },
	};
}

function hasGeminiCandidates(body: unknown): boolean {
	if (!isPlainObject(body) || !Array.isArray(body.candidates) || body.candidates.length === 0) {
		return false;
	}
	const first: unknown = body.candidates[0];
	if (!isPlainObject(first) || !isPlainObject(first.content)) return false;
	return Array.isArray(first.content.parts) && first.content.parts.length > 0;
}

// ---------------------------------------------------------------------------
// Reddit — api/submit, api/comment
// ---------------------------------------------------------------------------

/**
 * The Reddit node reads `responseData.json.data` for `api/submit` and
 * `responseData.json.data.things[0].data` for `api/comment`. Wrap the payload
 * into that envelope (unwrapping an already-enveloped body so we never
 * double-nest).
 */
function normalizeReddit(spec: NormalizableSpec, kind: 'submit' | 'comment'): void {
	const inner = extractRedditData(spec.body);
	const data = kind === 'comment' ? ensureRedditThings(inner) : inner;
	spec.body = { json: { errors: existingRedditErrors(spec.body), data } };
}

/** Peel any existing `{ json: { data } }` / `{ data }` wrapper down to the payload object. */
function extractRedditData(body: unknown): Record<string, unknown> {
	if (isPlainObject(body) && isPlainObject(body.json) && isPlainObject(body.json.data)) {
		return body.json.data;
	}
	if (isPlainObject(body) && isPlainObject(body.data)) return body.data;
	if (isPlainObject(body)) return body;
	return {};
}

function existingRedditErrors(body: unknown): unknown[] {
	if (isPlainObject(body) && isPlainObject(body.json) && Array.isArray(body.json.errors)) {
		return body.json.errors;
	}
	return [];
}

/** Ensure the comment envelope's `things[0].data` path exists. */
function ensureRedditThings(data: Record<string, unknown>): Record<string, unknown> {
	if (Array.isArray(data.things) && isPlainObject(data.things[0]) && data.things[0].data) {
		return data;
	}
	return { things: [{ kind: 't1', data }] };
}

// ---------------------------------------------------------------------------
// HubSpot — /contacts/v1/contact/createOrUpdate
// ---------------------------------------------------------------------------

/**
 * The HubSpot node reads `responseData.vid` (and `responseData.isNew`), then
 * interpolates the vid into a follow-up profile GET. Guarantee a numeric `vid`
 * and a boolean `isNew`.
 */
function normalizeHubspotUpsert(spec: NormalizableSpec): void {
	const body = isPlainObject(spec.body) ? spec.body : {};
	spec.body = {
		...body,
		vid: resolveHubspotVid(body),
		isNew: typeof body.isNew === 'boolean' ? body.isNew : true,
	};
}

function resolveHubspotVid(body: Record<string, unknown>): number {
	for (const candidate of [body.vid, body['canonical-vid'], body.id]) {
		if (typeof candidate === 'number' && Number.isFinite(candidate)) return candidate;
		if (typeof candidate === 'string' && /^\d+$/.test(candidate)) return Number(candidate);
	}
	return DEFAULT_HUBSPOT_VID;
}

// ---------------------------------------------------------------------------
// Gmail — messages.list
// ---------------------------------------------------------------------------

/**
 * The Gmail node's list operation reads `responseData.messages`. A recurring
 * model slip (despite quirk guidance) is answering with a bare ARRAY of
 * message objects — the node then sees zero messages and the workflow's list
 * branch silently does nothing. Wrap a bare array into the real envelope.
 */
function normalizeGmailMessagesList(spec: NormalizableSpec): void {
	if (!Array.isArray(spec.body)) return;
	spec.body = { messages: spec.body, resultSizeEstimate: spec.body.length };
}

// ---------------------------------------------------------------------------
// Google Docs — /documents/{id}:batchUpdate
// ---------------------------------------------------------------------------

/**
 * The Google Docs node reads `responseData.replies[0]` (and `Object.keys` on
 * it). A missing or empty `replies` array crashes it. Guarantee a non-empty
 * array — one empty reply object is a valid response (e.g. for insertText).
 */
function normalizeGoogleDocsBatchUpdate(spec: NormalizableSpec): void {
	const body = isPlainObject(spec.body) ? spec.body : {};
	const replies = Array.isArray(body.replies) && body.replies.length > 0 ? body.replies : [{}];
	spec.body = { ...body, replies };
}

// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------

/**
 * Coerce `spec.body` into the provider-accurate envelope for the request's
 * endpoint. No-op for non-json specs and for endpoints without a registered
 * normalizer. Runs AFTER the mechanical encoders in `applyEndpointNormalizers`.
 */
export function applyProviderShapeNormalizers(
	info: ProviderRequestInfo,
	spec: NormalizableSpec,
): void {
	if (spec.type !== 'json') return;

	const path = info.pathname.toLowerCase();
	const host = (info.hostname ?? '').toLowerCase();

	if (isOpenAiImagesGeneration(path)) return normalizeOpenAiImages(spec);
	const completionKind = isOpenAiCompletion(path);
	if (completionKind) return normalizeOpenAiCompletionText(spec, completionKind);
	if (isGeminiGenerate(host, path)) return normalizeGemini(spec);
	const redditKind = isRedditWrite(host, path, info.method);
	if (redditKind) return normalizeReddit(spec, redditKind);
	if (isHubspotUpsert(host, path)) return normalizeHubspotUpsert(spec);
	if (isGoogleDocsBatchUpdate(host, path)) return normalizeGoogleDocsBatchUpdate(spec);
	if (isGmailMessagesList(host, path, info.method)) return normalizeGmailMessagesList(spec);
}

/**
 * Read-only shape conformance check for the `submit_response` one-shot nudge.
 * Returns an "Invalid: …" message describing the missing provider path, or
 * undefined when the body already conforms (or the endpoint has no check).
 */
export function findProviderShapeViolation(
	info: ProviderRequestInfo,
	body: unknown,
): string | undefined {
	const path = info.pathname.toLowerCase();
	const host = (info.hostname ?? '').toLowerCase();

	if (isOpenAiImagesGeneration(path)) return openAiImagesViolation(body);
	const completionKind = isOpenAiCompletion(path);
	if (completionKind) return openAiCompletionTextViolation(body, completionKind);
	if (isGeminiGenerate(host, path)) return geminiViolation(body);
	const redditKind = isRedditWrite(host, path, info.method);
	if (redditKind) return redditViolation(body, redditKind);
	if (isHubspotUpsert(host, path)) return hubspotViolation(body);
	if (isGoogleDocsBatchUpdate(host, path)) return googleDocsViolation(body);
	if (isGmailMessagesList(host, path, info.method)) return gmailMessagesListViolation(body);

	return undefined;
}

function gmailMessagesListViolation(body: unknown): string | undefined {
	if (!Array.isArray(body)) return undefined;
	return 'Invalid: Gmail messages.list returns an OBJECT envelope `{ "messages": [{ "id", "threadId" }, ...], "resultSizeEstimate": <n> }` — the node reads response.messages, so a bare array yields zero messages. Resubmit wrapped in that envelope.';
}

function openAiImagesViolation(body: unknown): string | undefined {
	const data = isPlainObject(body) ? body.data : undefined;
	if (!Array.isArray(data) || data.length === 0) {
		return 'Invalid: OpenAI /v1/images/generations must return a JSON object with a `data` ARRAY of image objects, e.g. `{ "created": <unix>, "data": [{ "b64_json": "..." }] }`. Resubmit with that envelope.';
	}
	const brokenEntry = data.some(
		(entry) =>
			!isPlainObject(entry) ||
			(typeof entry.b64_json !== 'string' && typeof entry.url !== 'string'),
	);
	if (brokenEntry) {
		return 'Invalid: every OpenAI image `data[]` entry must carry a `b64_json` (base64 string) — the node decodes it. Resubmit with `data: [{ "b64_json": "..." }]`.';
	}
	return undefined;
}

function openAiCompletionTextViolation(
	body: unknown,
	kind: 'responses' | 'chat',
): string | undefined {
	if (!isPlainObject(body)) return undefined;
	if (kind === 'responses' && Array.isArray(body.output)) {
		for (const item of body.output) {
			if (!isPlainObject(item) || !Array.isArray(item.content)) continue;
			for (const part of item.content) {
				if (isPlainObject(part) && 'text' in part && typeof part.text !== 'string') {
					return 'Invalid: OpenAI /v1/responses `output[].content[].text` must be a STRING — when the answer is JSON, JSON-encode it into the string (e.g. "{\\"quotes\\":[...]}"), never embed the parsed object. Resubmit with `text` as a string.';
				}
			}
		}
	}
	if (kind === 'chat' && Array.isArray(body.choices)) {
		for (const choice of body.choices) {
			if (!isPlainObject(choice) || !isPlainObject(choice.message)) continue;
			const content = choice.message.content;
			if (content !== null && content !== undefined && typeof content !== 'string') {
				if (!Array.isArray(content)) {
					return 'Invalid: chat/completions `choices[].message.content` must be a STRING (or null with tool_calls) — when the answer is JSON, JSON-encode it into the string, never embed the parsed object. Resubmit with `content` as a string.';
				}
			}
		}
	}
	return undefined;
}

function geminiViolation(body: unknown): string | undefined {
	if (hasGeminiCandidates(body)) return undefined;
	return 'Invalid: Google Gemini responses must use the full envelope `{ "candidates": [{ "content": { "parts": [{ "text": "..." }] } }] }` — the node reads candidates[].content.parts[].text. Resubmit wrapped in that shape.';
}

function redditViolation(body: unknown, kind: 'submit' | 'comment'): string | undefined {
	const data = isPlainObject(body) && isPlainObject(body.json) ? body.json.data : undefined;
	if (!isPlainObject(data)) {
		return 'Invalid: Reddit write responses must use the `{ "json": { "errors": [], "data": { ... } } }` envelope — the node reads response.json.data. Resubmit wrapped in that shape.';
	}
	if (kind === 'comment') {
		const things = data.things;
		if (!Array.isArray(things) || !isPlainObject(things[0]) || !things[0].data) {
			return 'Invalid: Reddit api/comment responses put the comment under `json.data.things[0].data`. Resubmit with `{ "json": { "data": { "things": [{ "kind": "t1", "data": { ... } }] } } }`.';
		}
	}
	return undefined;
}

function hubspotViolation(body: unknown): string | undefined {
	const vid = isPlainObject(body) ? body.vid : undefined;
	if (typeof vid === 'number' || (typeof vid === 'string' && /^\d+$/.test(vid))) return undefined;
	return 'Invalid: HubSpot contacts/v1 createOrUpdate returns `{ "vid": <number>, "isNew": <bool> }` — the node reads response.vid. Resubmit with a numeric `vid`.';
}

function googleDocsViolation(body: unknown): string | undefined {
	const replies = isPlainObject(body) ? body.replies : undefined;
	if (Array.isArray(replies) && replies.length > 0) return undefined;
	return 'Invalid: Google Docs batchUpdate returns `{ "documentId": "...", "replies": [ ... ] }` with one reply per request — the node reads response.replies[0]. Resubmit with a non-empty `replies` array.';
}
