import type { AiInsightsPayload, AiInsightsResponse } from '@n8n/api-types';
import { aiInsightsResponseSchema } from '@n8n/api-types';
import { LicenseState, Logger } from '@n8n/backend-common';
import type { TestRun, User } from '@n8n/db';
import { EvaluationCollectionRepository } 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';

/**
 * Identifier reported in telemetry + the `modelUsed` field of the response
 * when generation falls through to the deterministic path. The frontend
 * uses this to gate any "Generated by AI" labelling — `deterministic` is
 * NOT an AI label, even though the response shape matches.
 */
export const DETERMINISTIC_MODEL_TAG = 'deterministic';

/**
 * Per-run summary used to build either the LLM prompt input or the
 * deterministic fallback. Intentionally narrow — no per-case detail at
 * this layer; that arrives in a follow-up.
 */
type RunSummary = {
	versionLabel: string;
	workflowVersionId: string | null;
	avgScore: number | null;
	metrics: Record<string, number>;
};

/**
 * Generates AI insights for an evaluation collection. The full flow per
 * spec §9 is: build compact prompt input → call LLM → validate output with
 * zod → cache. For this PR (TRUST-80 PR 2a) the LLM call is intentionally
 * deferred — we ship the **deterministic** path (spec §9.5 "fallback"
 * status) as the primary implementation so:
 *
 *  1. The endpoint contract is concrete and the frontend (PR 2b) can build
 *     against real responses.
 *  2. Caching, telemetry, license gating, and the response envelope are
 *     all exercised by tests.
 *  3. A focused follow-up wires the actual LLM provider without re-doing
 *     plumbing.
 *
 * `invokeAgent()` is the only method to replace when wiring the real model.
 * Everything else stays.
 */
@Service()
export class EvalInsightsService {
	constructor(
		private readonly collectionRepo: EvaluationCollectionRepository,
		private readonly licenseState: LicenseState,
		private readonly telemetry: Telemetry,
		private readonly logger: Logger,
	) {}

	async generateInsights(
		user: User,
		workflowId: string,
		collectionId: string,
		options: { forceRegenerate?: boolean } = {},
	): Promise<AiInsightsResponse> {
		// 1. License gate. AI Assistant entitlement is the same one gating
		// the rest of the AI feature surface; off-license tenants don't get
		// insights at all.
		if (!this.licenseState.isAiAssistantLicensed()) {
			throw new ForbiddenError('AI Assistant license required for eval-collection insights');
		}

		// 2. Load collection + runs. Single round-trip for the runs we'll
		// summarise.
		const detail = await this.collectionRepo.getDetailByIdAndWorkflowId(collectionId, workflowId);
		if (!detail) {
			throw new NotFoundError('Collection not found');
		}

		// 3. Cache hit short-circuit. Cached payload is the full envelope so
		// `status` / `generatedAt` / `modelUsed` round-trip unchanged.
		if (!options.forceRegenerate && detail.collection.insightsCache) {
			const cached = detail.collection.insightsCache as AiInsightsResponse;
			// Validate cache shape — if a previous run's schema diverged we'd
			// rather regenerate than hand a malformed envelope to the FE.
			const parsed = aiInsightsResponseSchema.safeParse(cached);
			if (parsed.success) return parsed.data;
			this.logger.warn('Cached insights failed schema validation; regenerating', {
				collectionId,
			});
		}

		// 4. Need at least two completed runs with metrics to say anything
		// useful. Compare-of-one isn't a comparison.
		//
		// Labels (A/B/C) are derived from the run's index in the *full*
		// collection — `detail.runs` is ordered `createdAt ASC` (see
		// `EvaluationCollectionRepository.getDetailByIdAndWorkflowId`). The
		// frontend assigns labels by the same position, so if a middle run
		// is filtered out here (still running, errored, or missing metrics)
		// we must keep the later runs on their original positions —
		// otherwise the insights response calls the third run "B" while the
		// frontend shows it as "C", and the user sees the winner / regression
		// attributed to the wrong workflow version.
		const summaries: RunSummary[] = [];
		detail.runs.forEach((run, originalIndex) => {
			if (run.status === 'completed' && run.metrics) {
				summaries.push(this.summariseRun(run, originalIndex));
			}
		});
		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 {
			// Reserved for the LLM path. Throws today; deterministic path
			// catches and produces the fallback envelope.
			const payload = await this.invokeAgent(detail.collection.name, summaries);
			response = {
				generatedAt: new Date().toISOString(),
				modelUsed: this.resolveModelName(),
				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),
			};
		}

		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): RunSummary {
		const metrics = this.coerceMetrics(run.metrics);
		const avg = this.averageScore(metrics);
		// 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 {
			versionLabel,
			workflowVersionId: run.workflowVersionId,
			avgScore: avg,
			metrics,
		};
	}

	private averageScore(metrics: Record<string, number>): number | null {
		const values = Object.values(metrics);
		if (values.length === 0) return null;
		return values.reduce((sum, v) => sum + v, 0) / values.length;
	}

	private coerceMetrics(
		metrics: Record<string, number | boolean> | null | undefined,
	): Record<string, number> {
		if (!metrics) return {};
		const out: Record<string, number> = {};
		for (const [k, v] of Object.entries(metrics)) {
			if (typeof v === 'number') out[k] = v;
			else if (typeof v === 'boolean') out[k] = v ? 1 : 0;
		}
		return out;
	}

	/**
	 * Deferred. Throws to signal "no LLM available"; callers handle by
	 * producing the deterministic envelope. Replace this method body with a
	 * real LLM call when the provider plumbing is in place — the rest of
	 * the service is provider-agnostic.
	 */
	private async invokeAgent(
		_collectionName: string,
		_summaries: RunSummary[],
	): Promise<AiInsightsPayload> {
		throw new Error('LLM agent not yet wired — fallback path will produce the response');
	}

	private resolveModelName(): string {
		// Placeholder. When `invokeAgent` is implemented this returns the
		// actual model id (e.g. `claude-sonnet-4-6`).
		return 'unknown';
	}

	/**
	 * Computes insights deterministically from raw per-version aggregates.
	 * No LLM involved — every value here is derivable arithmetically from
	 * the runs' metrics. The agent is expected to produce more nuanced
	 * narrative copy on top of the same underlying facts.
	 *
	 * Algorithm:
	 *  - **Winner** = highest `avgScore`. Ties resolved by first occurrence.
	 *  - **Regressions** = per-version, per-metric drops vs. the winner's
	 *    score on the same metric. Only included if delta ≥ 10 percentage
	 *    points (an arbitrary but visible threshold; spec doesn't pin one).
	 *  - **Suggested next** = generic "compare in isolation" recommendation
	 *    referencing the regressed metric with the largest delta.
	 */
	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 — return a neutral envelope. Validation upstream
			// already requires ≥2 completed runs, so this branch is mostly
			// defensive for the all-metrics-empty edge case.
			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.metrics).length} metric(s).`,
			},
			regressions,
			suggestedNext,
		};
	}

	private collectRegressions(
		scored: Array<RunSummary & { avgScore: number }>,
		winner: RunSummary & { avgScore: number },
	) {
		// Only include drops ≥10pp on the same metric the winner scored well
		// on. Smaller deltas surface as visual noise rather than 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.metrics)) {
				const runScore = run.metrics[metric];
				if (typeof runScore !== 'number') continue;
				const delta = runScore - winnerScore;
				if (delta >= -REGRESSION_DELTA_THRESHOLD) continue;
				regressions.push({
					versionLabel: run.versionLabel,
					metric,
					delta: Number((delta * 100).toFixed(1)),
					headline: `${run.versionLabel} regressed on ${metric}`,
					body: `${run.versionLabel} scored ${this.formatScore(runScore)} on ${metric}, ${this.formatScore(Math.abs(delta * 100))} 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 {
		// Compact 0–1 score formatting; agent prose can override per locale.
		return score.toFixed(2);
	}
}
