import type { Agent } from '@n8n/agents';
import type { AiInsightsPayload, AiInsightsResponse, MetricScale } from '@n8n/api-types';
import {
	aiInsightsPayloadSchema,
	aiInsightsResponseSchema,
	averageNormalizedScore,
	metricScalesFromConfig,
	normalizedScores,
} from '@n8n/api-types';
import { LicenseState, Logger } from '@n8n/backend-common';
import type { EvaluationConfig, TestRun, User } from '@n8n/db';
import { EvaluationCollectionRepository, EvaluationConfigRepository } from '@n8n/db';
import { Service } from '@n8n/di';

import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { Telemetry } from '@/telemetry';

import { runMetricScales } from '../metric-scales';
import { InsightsContextBuilder } from './insights-context-builder';
import type { InsightsContext, InsightsContextVersion } from './insights-context-builder';
import { InsightsModelResolver } from './insights-model-resolver';

// System prompt for the insights agent. Kept terse; the per-collection facts
// (scores, node diffs, regressed cases) arrive in the user prompt, and the
// output shape is enforced by `structuredOutput(aiInsightsPayloadSchema)`.
const INSIGHTS_SYSTEM_PROMPT = [
	'You are an evaluation analyst for n8n workflow evaluations.',
	'You compare workflow versions run against the same dataset and explain the results.',
	'Given the JSON context, produce:',
	'- `winner`: set `versionLabel` to the base version letter given in the context; explain why it leads.',
	'- `regressions`: versions that scored notably worse than the winner on a specific metric',
	'  (empty array if none). `delta` is the percentage-point difference vs the winner (negative = worse).',
	'- `suggestedNext`: one concrete next experiment, with a testable `hypothesis`.',
	'Ground every statement in the provided metric scores, workflow node diffs, and regressed cases.',
	'Each modified node carries its changed prompt text (before → after) under `promptChanges` — when',
	'such a change explains a regression, quote the specific instruction that changed rather than',
	'guessing (e.g. a system prompt that tells the model to answer a category incorrectly). Cite metric',
	'names and node names. Scores are percentages (0–100).',
	'Keep each headline under 120 characters and each body under 280 characters.',
].join('\n');

/**
 * `modelUsed` value for the deterministic path. The FE keys "Generated by AI"
 * labelling off this — `deterministic` is NOT an AI label.
 */
export const DETERMINISTIC_MODEL_TAG = 'deterministic';

// Bound a single LLM generation so a hung provider can't pin the request (and
// the card's loading skeleton) for the transport default (minutes).
const INSIGHTS_GENERATE_TIMEOUT_MS = 60_000;

// Cap regressions surfaced to the card after dedupe — the model can repeat the
// same real regression, and the UI only shows a handful.
const MAX_REGRESSIONS = 10;

/**
 * Per-run summary used to select the winner and seed both the LLM prompt input
 * and the deterministic fallback. `testRunId` lets the context builder load
 * per-case executions for the LLM path.
 */
type RunSummary = {
	testRunId: string;
	versionLabel: string;
	workflowVersionId: string | null;
	avgScore: number | null;
	// Per-metric scores normalized to [0, 1] (operational metrics excluded), so
	// winner/regression comparisons reflect quality rather than token totals.
	scores: Record<string, number>;
	// This run's own metric scales (its frozen snapshot), so per-case scores in the
	// LLM context normalize on the same scales as the aggregate — not the current
	// config, which may have changed since the run.
	metricScales: Record<string, MetricScale>;
};

/**
 * Generates AI insights for an evaluation collection: license gate → load
 * collection → cache short-circuit → require ≥2 scored runs → call the LLM via
 * `invokeAgent()` → validate against `aiInsightsPayloadSchema` → cache.
 *
 * The LLM reuses the collection's eval-config judge model + credential
 * (resolved by {@link InsightsModelResolver}) and reasons over the winner's
 * scores, per-version node diffs, and top regressed cases (assembled by
 * {@link InsightsContextBuilder}). When no supported judge model is configured,
 * or the model errors / returns invalid output after a retry, the service falls
 * back to the deterministic summary (`status: 'fallback'`).
 */
@Service()
export class EvalInsightsService {
	constructor(
		private readonly collectionRepo: EvaluationCollectionRepository,
		private readonly evalConfigRepo: EvaluationConfigRepository,
		private readonly licenseState: LicenseState,
		private readonly telemetry: Telemetry,
		private readonly logger: Logger,
		private readonly modelResolver: InsightsModelResolver,
		private readonly contextBuilder: InsightsContextBuilder,
	) {}

	async generateInsights(
		user: User,
		workflowId: string,
		collectionId: string,
		options: { forceRegenerate?: boolean } = {},
	): Promise<AiInsightsResponse> {
		// AI Assistant entitlement gates the whole AI surface, insights included.
		if (!this.licenseState.isAiAssistantLicensed()) {
			throw new ForbiddenError('AI Assistant license required for eval-collection insights');
		}

		const detail = await this.collectionRepo.getDetailByIdAndWorkflowId(collectionId, workflowId);
		if (!detail) {
			throw new NotFoundError('Collection not found');
		}

		// Serve only a cached *ok* envelope. A cached `fallback` is treated as a
		// miss and regenerated: without a Regenerate button, caching fallbacks
		// would otherwise pin a collection to the deterministic summary forever
		// after one transient LLM error (and never upgrade stub-era fallbacks).
		if (!options.forceRegenerate && detail.collection.insightsCache) {
			const cached = detail.collection.insightsCache as AiInsightsResponse;
			// Regenerate rather than hand a schema-diverged envelope to the FE.
			const parsed = aiInsightsResponseSchema.safeParse(cached);
			if (parsed.success && parsed.data.status === 'ok') return parsed.data;
		}

		// Load the eval config once; both the scale map and the judge-model
		// resolver derive from it (threaded through to avoid a second lookup).
		const config = await this.evalConfigRepo.findByIdAndWorkflowId(
			detail.collection.evaluationConfigId,
			workflowId,
		);
		// Default scales from the current config (name-based heuristic when gone).
		// Each run overrides these with its own frozen snapshot below, so a later
		// config edit can't misnormalize an already-completed run's values.
		const scaleByMetric = config ? metricScalesFromConfig(config.metrics) : {};

		// Labels (A/B/C) map to each run's index in the *full* collection
		// (`detail.runs` is `createdAt ASC`), and the FE labels by the same
		// position — so keep filtered-out runs on their original index or the
		// winner/regression gets attributed to the wrong version.
		const summaries: RunSummary[] = [];
		detail.runs.forEach((run, originalIndex) => {
			if (run.status === 'completed' && run.metrics) {
				summaries.push(this.summariseRun(run, originalIndex, runMetricScales(run, scaleByMetric)));
			}
		});
		if (summaries.length < 2) {
			throw new BadRequestError(
				'Collection needs at least 2 completed runs with metrics before insights can be generated',
			);
		}
		const startMs = Date.now();
		let response: AiInsightsResponse;

		try {
			const winner = this.pickWinner(summaries);
			if (!winner) throw new Error('No scored runs to summarise');
			const { payload, modelId } = await this.invokeAgent({
				user,
				workflowId,
				collectionName: detail.collection.name,
				evaluationConfigId: detail.collection.evaluationConfigId,
				config,
				summaries,
				winner,
			});
			response = {
				generatedAt: new Date().toISOString(),
				modelUsed: modelId,
				status: 'ok',
				insights: payload,
			};
		} catch (error) {
			this.logger.debug('Insights agent unavailable; falling back to deterministic summary', {
				collectionId,
				error: error instanceof Error ? error.message : String(error),
			});
			response = {
				generatedAt: new Date().toISOString(),
				modelUsed: DETERMINISTIC_MODEL_TAG,
				status: 'fallback',
				insights: this.buildDeterministicInsights(summaries),
			};
		}

		// Cache only successful envelopes; a fallback stays uncached so the next
		// view re-attempts the LLM (see the cache short-circuit above).
		if (response.status === 'ok') {
			await this.collectionRepo.updateInsightsCache(collectionId, response);
		}

		this.telemetry.track('Eval collection insights generated', {
			user_id: user.id,
			workflow_id: workflowId,
			collection_id: collectionId,
			model_used: response.modelUsed,
			duration_ms: Date.now() - startMs,
			status: response.status,
			regressions_found: response.insights.regressions.length,
		});

		return response;
	}

	// ---- internals ----

	private summariseRun(
		run: TestRun,
		index: number,
		scaleByMetric: Record<string, MetricScale>,
	): RunSummary {
		// Label by index letter — A/B/C — so the FE legend chips line up. The
		// agent (and the deterministic path) refer back to these labels.
		const versionLabel = String.fromCharCode(0x41 + index);
		return {
			testRunId: run.id,
			versionLabel,
			workflowVersionId: run.workflowVersionId,
			avgScore: averageNormalizedScore(run.metrics, scaleByMetric),
			scores: normalizedScores(run.metrics, scaleByMetric),
			metricScales: scaleByMetric,
		};
	}

	/** Highest-`avgScore` run, or null when nothing scored. */
	private pickWinner(summaries: RunSummary[]): RunSummary | null {
		const scored = summaries.filter(
			(summary): summary is RunSummary & { avgScore: number } => summary.avgScore !== null,
		);
		if (scored.length === 0) return null;
		return scored.reduce((best, summary) => (summary.avgScore > best.avgScore ? summary : best));
	}

	/**
	 * Calls the LLM to produce the insights payload. Reuses the eval-config
	 * judge model + credential and reasons over the winner-relative context.
	 * Throws when no supported model is configured or the output can't be
	 * validated after a retry — the caller then falls back to deterministic.
	 */
	private async invokeAgent(params: {
		user: User;
		workflowId: string;
		collectionName: string;
		evaluationConfigId: string;
		config: EvaluationConfig | null;
		summaries: RunSummary[];
		winner: RunSummary;
	}): Promise<{ payload: AiInsightsPayload; modelId: string }> {
		const { user, workflowId, collectionName, evaluationConfigId, config, summaries, winner } =
			params;

		const resolved = await this.modelResolver.resolve(user, workflowId, evaluationConfigId, config);
		if (!resolved) {
			throw new Error('No supported LLM judge model configured for insights');
		}

		const context = await this.contextBuilder.build(workflowId, {
			collectionName,
			winnerLabel: winner.versionLabel,
			versions: summaries.map(
				(summary): InsightsContextVersion => ({
					testRunId: summary.testRunId,
					workflowVersionId: summary.workflowVersionId,
					versionLabel: summary.versionLabel,
					avgScore: summary.avgScore,
					scores: summary.scores,
					metricScales: summary.metricScales,
				}),
			),
		});

		// Lazy-load the agents SDK (+ `ai` core) so it doesn't enter every
		// instance's boot via the unconditionally-imported controller — only this
		// flag-gated path pays for it.
		const { Agent } = await import('@n8n/agents');
		// Intentionally tool-less: the prompt embeds untrusted workflow content
		// (node prompt text + case I/O), so a planted instruction can only bias
		// the narrative, never trigger a tool/exfil. Reconcile + zod bound the
		// output. Adding a tool here would open an exfiltration channel — don't.
		const agent = new Agent('eval-insights')
			.model(resolved.modelConfig)
			.instructions(INSIGHTS_SYSTEM_PROMPT)
			.structuredOutput(aiInsightsPayloadSchema);

		const payload = await this.generateValidated(
			agent,
			this.buildUserPrompt(context),
			context.baseVersionLabel,
		);
		return { payload: this.reconcile(payload, context), modelId: resolved.modelId };
	}

	// Keep the model's regressions consistent with the data it was given: one per
	// (version, metric) — the model can repeat the same real regression — and only
	// where the context scores actually support it (a known, non-base version that
	// really scored below the base on the named metric). `delta` is overwritten
	// with the true percentage-point gap rather than trusting the model's number.
	// The winner label is validated upstream (see `generateValidated`), so it's
	// not relabeled here. Capped so the card can't be flooded.
	private reconcile(payload: AiInsightsPayload, context: InsightsContext): AiInsightsPayload {
		const versionByLabel = new Map(context.versions.map((version) => [version.label, version]));
		const base = versionByLabel.get(context.baseVersionLabel);
		const seen = new Set<string>();
		const regressions: AiInsightsPayload['regressions'] = [];
		for (const regression of payload.regressions) {
			if (regression.versionLabel === context.baseVersionLabel) continue;
			const versionScore = versionByLabel.get(regression.versionLabel)?.metricScores[
				regression.metric
			];
			const baseScore = base?.metricScores[regression.metric];
			if (typeof versionScore !== 'number' || typeof baseScore !== 'number') continue;
			if (versionScore >= baseScore) continue;
			const key = `${regression.versionLabel} ${regression.metric}`;
			if (seen.has(key)) continue;
			seen.add(key);
			regressions.push({ ...regression, delta: versionScore - baseScore });
			if (regressions.length >= MAX_REGRESSIONS) break;
		}
		return { ...payload, regressions };
	}

	// Generate + validate the payload, with one stricter retry — a real model's
	// structured output can drift. Invalid = wrong shape OR a winner label other
	// than the base: a wrong label means the model ignored an explicit instruction,
	// so we retry then fall back rather than ship copy that contradicts the badge.
	// Each generation is time-bounded so a hung provider can't pin the request.
	private async generateValidated(
		agent: Agent,
		userPrompt: string,
		baseVersionLabel: string,
	): Promise<AiInsightsPayload> {
		const attempt = async (prompt: string): Promise<AiInsightsPayload | null> => {
			const result = await agent.generate(prompt, {
				abortSignal: AbortSignal.timeout(INSIGHTS_GENERATE_TIMEOUT_MS),
			});
			const parsed = aiInsightsPayloadSchema.safeParse(result.structuredOutput);
			if (!parsed.success) return null;
			if (parsed.data.winner.versionLabel !== baseVersionLabel) return null;
			return parsed.data;
		};

		const first = await attempt(userPrompt);
		if (first) return first;

		const retry = await attempt(
			`${userPrompt}\n\nReturn ONLY a JSON object matching the required schema exactly — no extra keys, no prose. The winner's versionLabel MUST be "${baseVersionLabel}".`,
		);
		if (retry) return retry;

		throw new Error('LLM insights output failed validation after retry');
	}

	private buildUserPrompt(context: InsightsContext): string {
		return [
			'Compare these workflow versions, all evaluated against the same dataset.',
			`The base version "${context.baseVersionLabel}" is the winner — write the winner card about it`,
			'and set winner.versionLabel to that letter. Scores are percentages (0–100). Ground every',
			'statement in the metric scores, workflow node diffs (including changed prompt text under',
			'`promptChanges`), and regressed cases below.',
			'',
			JSON.stringify(context),
		].join('\n');
	}

	/**
	 * Computes insights arithmetically from per-version aggregates (no LLM):
	 * winner = highest `avgScore` (ties → first), regressions = per-metric drops
	 * ≥10pp vs the winner, suggested-next targets the largest regression.
	 */
	private buildDeterministicInsights(summaries: RunSummary[]): AiInsightsPayload {
		const scored = summaries.filter(
			(s): s is RunSummary & { avgScore: number } => s.avgScore !== null,
		);

		if (scored.length === 0) {
			// No scored runs — neutral envelope. Defensive: the all-metrics-empty
			// edge case, since upstream already requires ≥2 completed runs.
			return {
				winner: {
					versionLabel: summaries[0]?.versionLabel ?? 'A',
					headline: 'No scored runs',
					body: 'No runs in this collection produced numeric metrics yet.',
				},
				regressions: [],
				suggestedNext: {
					headline: 'Re-run with metric outputs configured',
					body: 'Add evaluation set-metrics nodes to the workflow so insights can compare runs.',
					hypothesis: 'Without numeric metrics there is nothing to compare across versions.',
				},
			};
		}

		const winner = scored.reduce((best, s) => (s.avgScore > best.avgScore ? s : best));
		const regressions = this.collectRegressions(scored, winner);
		const suggestedNext = this.composeSuggestedNext(winner, regressions);

		return {
			winner: {
				versionLabel: winner.versionLabel,
				headline: `${winner.versionLabel} is the winner`,
				body: `${winner.versionLabel} leads on average score (${this.formatScore(winner.avgScore)}) across ${Object.keys(winner.scores).length} metric(s).`,
			},
			regressions,
			suggestedNext,
		};
	}

	private collectRegressions(
		scored: Array<RunSummary & { avgScore: number }>,
		winner: RunSummary & { avgScore: number },
	) {
		// Only drops ≥10pp; smaller deltas are visual noise, not insight.
		const REGRESSION_DELTA_THRESHOLD = 0.1;
		const regressions: AiInsightsPayload['regressions'] = [];
		for (const run of scored) {
			if (run.versionLabel === winner.versionLabel) continue;
			for (const [metric, winnerScore] of Object.entries(winner.scores)) {
				const runScore = run.scores[metric];
				if (typeof runScore !== 'number') continue;
				const delta = runScore - winnerScore;
				if (delta >= -REGRESSION_DELTA_THRESHOLD) continue;
				// Report the [-1, 1] delta as signed percentage points.
				const deltaPoints = Number((delta * 100).toFixed(1));
				regressions.push({
					versionLabel: run.versionLabel,
					metric,
					delta: deltaPoints,
					headline: `${run.versionLabel} regressed on ${metric}`,
					body: `${run.versionLabel} scored ${this.formatScore(runScore)} on ${metric}, ${Math.abs(deltaPoints).toFixed(1)} percentage points below ${winner.versionLabel}.`,
				});
			}
		}
		return regressions;
	}

	private composeSuggestedNext(
		winner: RunSummary & { avgScore: number },
		regressions: AiInsightsPayload['regressions'],
	): AiInsightsPayload['suggestedNext'] {
		if (regressions.length === 0) {
			return {
				headline: `Lock in ${winner.versionLabel} as the baseline`,
				body: `No version regressed sharply against ${winner.versionLabel}. Promote it and use it as the comparison baseline for future experiments.`,
				hypothesis: `${winner.versionLabel} is a solid starting point; further gains require new variants rather than fixing regressions.`,
			};
		}
		const worst = regressions.reduce((w, r) => (r.delta < w.delta ? r : w));
		return {
			headline: `Investigate ${worst.metric} regression on ${worst.versionLabel}`,
			body: `${worst.versionLabel} lost ${Math.abs(worst.delta).toFixed(1)} percentage points on ${worst.metric} vs ${winner.versionLabel}. Try a variant that keeps ${winner.versionLabel}'s configuration for that metric and changes only the rest.`,
			hypothesis: `If isolating ${winner.versionLabel}'s ${worst.metric} configuration into a new variant recovers the lost points, the rest of ${worst.versionLabel}'s changes were the regression's cause.`,
		};
	}

	private formatScore(score: number): string {
		// [0, 1] score as whole-percent so prose reads "83%" not "0.83".
		return `${Math.round(score * 100)}%`;
	}
}
